Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: access-api forwards store/ and upload/ invocations to upload-api #334

Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0f79d89
working test in packages/access-api/test/upload.test.js
gobengo Jan 11, 2023
70a7576
make store/list proxy test pass by overriding type on issuer
gobengo Jan 11, 2023
ab27309
upload-api-proxy tests ensure no error if a WEB3_STORAGE_PRIVATE_KEY …
gobengo Jan 11, 2023
658b8f1
add upload-api-proxy test ensuring irrelevant invocations are rejected
gobengo Jan 11, 2023
6a633c6
rm eslint-disables at top of upload-api-proxy
gobengo Jan 11, 2023
fc665af
rename test/upload.test.js to test/store-list.js
gobengo Jan 11, 2023
f0ac9ad
add test upload-api-proxy can get all caps from Store.all
gobengo Jan 11, 2023
ddb1351
test parserCapabilities
gobengo Jan 11, 2023
a692aec
initial Store.all test not working
gobengo Jan 12, 2023
d596bc1
test can proxy Store.all
gobengo Jan 12, 2023
fa6a437
serve proxies Upload.all
gobengo Jan 12, 2023
69ee9de
cleanup
gobengo Jan 12, 2023
88a1c07
Merge branch 'main' into 331-make-it-so-access-api-can-proxy-store-an…
gobengo Jan 12, 2023
2395049
access-api uses UploadApiProxyService without options.signer
gobengo Jan 12, 2023
fcc80ea
access-api/src/config imports from ucanto libs, not dag-ucan
gobengo Jan 12, 2023
9559d0e
access-api prefer consistent import names of ucanto/{core,interface}
gobengo Jan 12, 2023
373e983
improve @returns of InvocationResponder in upload-api-proxy to be mor…
gobengo Jan 12, 2023
8b1d76b
fix tsc build by fixing import in access-api/src/config
gobengo Jan 12, 2023
b9a3ed8
improve createInvocationResponder type override to be Ucanto.Signer v…
gobengo Jan 12, 2023
b9c2644
replace at-mention Gozala with pointer to issue he created
gobengo Jan 12, 2023
b46dd85
upload-api-proxy: replace AudienceConnections class with a pojo
gobengo Jan 12, 2023
49144ad
mv isUploadApiStack out of src and into test/helpers/utils.js
gobengo Jan 12, 2023
51794b0
remove parserAbilities tests that are redundant
gobengo Jan 13, 2023
5eb45fa
access-api: add test/ucanto-proxy.test.js
gobengo Jan 13, 2023
3f50270
upload-api-proxy infers service type from capabilities module
gobengo Jan 13, 2023
08981d8
simplify upload-api-proxy and remove redundancies by e.g. inferring s…
gobengo Jan 13, 2023
8b03b1b
remove duplicate @template in config configureVerifier
gobengo Jan 13, 2023
94614d0
minimize type-only imports from @web3-storage/capabilities in upload-…
gobengo Jan 13, 2023
fa1245e
access-api remove InvocationResponder type as it is essentially Servi…
gobengo Jan 13, 2023
3a46454
decompose UploadApiProxyService into createUploadProxy and createStor…
gobengo Jan 13, 2023
394662b
tests work offline. upload-api urls can be overridden via env var
gobengo Jan 14, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
18 changes: 18 additions & 0 deletions 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 dagUcan from '@ipld/dag-ucan'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: We re-export all of the types from the dag-ucan in the ucanto interface & it would be better to use those instead, so we don't have a chance of having two diff versions of dag-ucan in the tree.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I updated to that, and will prefer importing from ucanto libs in the future instead of dag-ucan.

import { UCAN } from '@ucanto/interface';
import { DID } from '@ucanto/core';

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


/**
* Loads configuration variables from the global environment and returns a JS object
Expand Down Expand Up @@ -128,3 +130,19 @@ export function configureSigner(config) {
}
return signer
}

/**
* @template {dagUcan.DID} ConfigDID
* @template {dagUcan.DID} [ID=dagUcan.DID]
* @template {dagUcan.SigAlg} [Alg=dagUcan.SigAlg]
* @param {object} config
* @param {ConfigDID} [config.DID] - public DID for the service
* @param {import('@ucanto/interface').Verifier<ID,Alg>} verifier
* @returns {import('@ucanto/interface').Verifier<ConfigDID|ID,Alg>}
*/
export function configureVerifier(config, verifier) {
if (config.DID) {
return verifier.withDID(DID.parse(config.DID).did())
}
return verifier
}
5 changes: 5 additions & 0 deletions packages/access-api/src/service/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@ import {
} from '@web3-storage/access/encoding'
import { voucherClaimProvider } from './voucher-claim.js'
import { voucherRedeemProvider } from './voucher-redeem.js'
import { UploadApiProxyService } from './upload-api-proxy.js'

/**
* @param {import('../bindings').RouteContext} ctx
* @returns {import('@web3-storage/access/types').Service}
*/
export function service(ctx) {
return {
...UploadApiProxyService.forSigner({
signer: ctx.signer,
fetch: globalThis.fetch,
}),
voucher: {
claim: voucherClaimProvider(ctx),
redeem: voucherRedeemProvider(ctx),
Expand Down
267 changes: 267 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,267 @@
import * as Client from '@ucanto/client'
import * as CAR from '@ucanto/transport/car'
import * as CBOR from '@ucanto/transport/cbor'
// eslint-disable-next-line no-unused-vars
import * as dagUcan from '@ipld/dag-ucan'
import * as dagUcanDid from '@ipld/dag-ucan/did'
import * as HTTP from '@ucanto/transport/http'
// eslint-disable-next-line no-unused-vars
import * as Store from '@web3-storage/capabilities/store'
// eslint-disable-next-line no-unused-vars
import * as Upload from '@web3-storage/capabilities/upload'
// eslint-disable-next-line no-unused-vars
import * as iucanto from '@ucanto/interface'
// eslint-disable-next-line no-unused-vars
import * as ed25519 from '@ucanto/principal/ed25519'

/**
* @template {iucanto.Capability} C
* @template [Success=unknown]
* @template {{ error: true }} [Failure={error:true}]
* @callback InvocationResponder
* @param {iucanto.Invocation<C>} invocationIn
* @param {iucanto.InvocationContext} context
* @returns {Promise<iucanto.Result<Success, Failure>>}
*/

/**
* @typedef StoreService
* @property {InvocationResponder<iucanto.InferInvokedCapability<typeof Store.add>>} add
* @property {InvocationResponder<iucanto.InferInvokedCapability<typeof Store.list>>} list
* @property {InvocationResponder<iucanto.InferInvokedCapability<typeof Store.remove>>} remove
*/

/**
* @typedef UploadService
* @property {InvocationResponder<iucanto.InferInvokedCapability<typeof Upload.add>>} add
* @property {InvocationResponder<iucanto.InferInvokedCapability<typeof Upload.list>>} list
* @property {InvocationResponder<iucanto.InferInvokedCapability<typeof Upload.remove>>} remove
*/

/**
* @template {iucanto.Capability} C
* @template [Success=unknown]
* @template {{ error: true }} [Failure={error:true}]
* @param {object} options
* @param {Pick<Map<dagUcan.DID, iucanto.ConnectionView<any>>, 'get'>} options.connections
* @param {iucanto.Signer} [options.signer]
* @returns {InvocationResponder<C, Success, Failure>}
*/
function createInvocationResponder(options) {
/**
* @template {import('@ucanto/interface').Capability} Capability
* @param {iucanto.Invocation<Capability>} invocationIn
* @param {iucanto.InvocationContext} context
* @returns {Promise<iucanto.Result<any, { error: true }>>}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Do we need the type annotation for return ? If we don't I'd rather leave it out than introduce any, because TS often can infer more precise types and any would degrade it. When we do have to use any I like having comment explaining why we resort to it because that info is often lost.

This kind of stuff especially becomes useful when updating to new TS with breaking changes. In my experience avoiding any when possible and commenting when needed really helps with such updates.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great find. I agree on avoiding any.

I can't remove this @returns while also appeasing the @returns {InvocationResponder<C, Success, Failure>} on createInvocationResponder itself. Because that's generic, it's probalby fine/useful to have a generic @returns right?

I will update it so this inner @returns is Promise<Ucanto.Result<Success, Failure>>, which works and is much more generic.

if you prefer to remove the (now generic) @returns alltogether, say so and I'll do it here, or feel free to just remove it as fast follow.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

*/
return async function handleInvocation(invocationIn, context) {
const connection = options.connections.get(invocationIn.audience.did())
if (!connection) {
throw new Error(
`unable to get connection for audience ${invocationIn.audience.did()}}`
)
}
// eslint-disable-next-line unicorn/prefer-logical-operator-over-ternary
const proxyInvocationIssuer = options.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`
options.signer
: // this works, but involves lying about the issuer type (it wants a Signer but context.id is only a Verifier)
// @Gozala can we make it so `iucanto.InvocationOptions['issuer']` can be a Verifier and not just Signer?
/** @type {ed25519.Signer.Signer} */ (context.id)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to cast this and specifically why do we need to be specific about it been ed25519 it seems wrong to have such an invariant.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're right. Ucanto.Signer works and is more generic.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


const [result] = await Client.execute(
[
Client.invoke({
issuer: proxyInvocationIssuer,
capability: invocationIn.capabilities[0],
audience: invocationIn.audience,
proofs: [invocationIn],
}),
],
/** @type {Client.ConnectionView<any>} */ (connection)
)
return result
}
}

/**
* @template {Record<string, any>} T
* @param {object} options
* @param {iucanto.Signer} [options.signer]
* @param {Pick<Map<dagUcan.DID, iucanto.ConnectionView<T>>, 'get'>} options.connections
*/
function createProxyStoreService(options) {
const handleInvocation = createInvocationResponder(options)
/**
* @type {StoreService}
*/
const store = {
add: handleInvocation,
list: handleInvocation,
remove: handleInvocation,
}
return store
}

/**
* @template {Record<string, any>} T
* @param {object} options
* @param {iucanto.Signer} [options.signer]
* @param {Pick<Map<dagUcan.DID, iucanto.ConnectionView<T>>, 'get'>} options.connections
*/
function createProxyUploadService(options) {
const handleInvocation = createInvocationResponder(options)
/**
* @type {UploadService}
*/
const store = {
add: handleInvocation,
list: handleInvocation,
remove: handleInvocation,
}
return store
}

/**
* @typedef UcantoHttpConnectionOptions
* @property {dagUcan.DID} audience
* @property {typeof globalThis.fetch} options.fetch
* @property {URL} options.url
*/

/**
* @implements {Pick<Map<dagUcan.DID, iucanto.Connection<any>>, 'get'>}
*/
class AudienceConnections {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This whole class seems like incidental complexity, things would be a whole lot simpler with just config object and static function for get method.

P.S.: I know that's not a popular opinion, yet I like to share it in a hope to bring peers to a true side :P

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this like what you had in mind? b46dd85

P.S.: I know that's not a popular opinion, yet I like to share it in a hope to bring peers to a true side :P

Eh, I don't disagree necessarily. good suggestion.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not exactly, sorry for not been very clear. In linked version you still have audienceConnections which has get function. When I criticize classes what I mean is:

Separate data part which is basically a state from the behavior which are all the methods

That way it does not matter how you constructed state, you can just pass it into a function and it will compute whatever. Separating data and behavior de-tangles two allowing you to store data in e.g. indexDB and then apply function after reading it from indexDB without having to built a class first. Not the case here but complex class heirerchies also often entangle initialization order.

I think there are only two valid reasons to resort to a classes with methods:

  1. When you have to deal with open-ended variant space. In other sometimes you have many different state options but only one of them will be used in practice. In this scenario dispatching from one function introduces unnecessary coordination and defining interface is better option.
  2. When your methods are simply a convenience sugar for the static functions. I do it all the time to allow method chaining and remove need for importing lot of dependencies. In this case they don't tangle anything they are nothing but a sugar

I would argue use of classes with methods for any other reason just introduces incidental complexity and gets between you and data nothing else.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Gozala ah I understand what you mean now.

Separating data and behavior de-tangles two allowing you to store data in e.g. indexDB and then apply function after reading it from indexDB without having to built a class first.

def have run into that. That helped me understand.

/** @type {Record<dagUcan.DID, URL>} */
#audienceToUrl
/** @type {undefined|iucanto.ConnectionView<any>} */
#defaultConnection
/** @type {typeof globalThis.fetch} */
#fetch

/**
* @param {UcantoHttpConnectionOptions} options
* @returns {iucanto.ConnectionView<any>}
*/
static createConnection(options) {
return Client.connect({
id: dagUcanDid.parse(options.audience),
encoder: CAR,
decoder: CBOR,
channel: HTTP.open({
fetch: options.fetch,
url: options.url,
}),
})
}

/**
* @param {object} options
* @param {Record<dagUcan.DID, URL>} options.audienceToUrl
* @param {UcantoHttpConnectionOptions} [options.defaultConnection]
* @param {typeof globalThis.fetch} options.fetch
*/
constructor(options) {
this.#fetch = options.fetch
this.#audienceToUrl = options.audienceToUrl
this.#defaultConnection = options.defaultConnection
? AudienceConnections.createConnection(options.defaultConnection)
: undefined
}

/**
* @param {dagUcan.DID} audience
*/
get(audience) {
if (audience in this.#audienceToUrl) {
return AudienceConnections.createConnection({
audience,
fetch: this.#fetch,
url: this.#audienceToUrl[audience],
})
}
return this.#defaultConnection
}
}

export class UploadApiProxyService {
/** @type {StoreService} */
store
/** @type {UploadService} */
upload

/**
* @type {Record<string, {
* audience: dagUcan.DID,
* url: URL,
* }>}
*/
static #environments = {
production: {
audience: 'did:web:web3.storage',
url: new URL('https://up.web3.storage'),
},
staging: {
audience: 'did:web:staging.web3.storage',
url: new URL('https://staging.up.web3.storage'),
},
}

/**
* @template {StoreService} T
* @param {object} options
* @param {iucanto.Signer} options.signer
* @param {typeof globalThis.fetch} options.fetch
*/
static forSigner(options) {
// eslint-disable-next-line unicorn/no-array-reduce
const audienceToUrl = Object.values(this.#environments).reduce(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we ever need to have multiple connections in practice ? I'd expect we use one in one staging and the other in production, if so it seems like there's bunch of unnecessary code to be maintained with added complexity costs.

As a side note we should probably add code coverage which would decentivize adding code which isn't used.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we ever need to have multiple connections in practice ?

No. Though we could also ask "Do we ever need a unified access-api + upload-api single environment service in practice?". Not yet, but I think we both believe it will make working with the total API more intuitive (i.e. api user won't have to worry about which service to invoke).

When we talked I like your vision of 'If you send an invocation to any API, it should try to route it to a place that can handle it'. i.e. unifying api across services. Doing it this way also unifies across environments. I don't think it's strictly necessary, but I do believe it will make working with the overall platform slightly more intuitive. (i.e. api user won't have to worry about which environment's 'unified' api to invoke).

I definitely agree code coverage could be useful. #339 (comment)

(acc, { audience, url }) => {
acc[audience] = url
return acc
},
/** @type {Record<dagUcan.DID, URL>} */ ({})
)
const connections = new AudienceConnections({
audienceToUrl,
defaultConnection: {
fetch: options.fetch,
...this.#environments.production,
},
fetch: options.fetch,
})
const proxyOptions = {
signer: options.signer,
connections,
}
return new this(
createProxyStoreService(proxyOptions),
createProxyUploadService(proxyOptions)
)
}

/**
* @protected
* @param {StoreService} store
* @param {UploadService} upload
*/
constructor(store, upload) {
this.store = store
this.upload = upload
}
}

/**
* 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')
}
7 changes: 5 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 @@ -53,6 +54,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 +63,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 +73,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
2 changes: 1 addition & 1 deletion packages/access-api/test/helpers/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading