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 24 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
20 changes: 19 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 @@ -128,3 +130,19 @@ export function configureSigner(config) {
}
return signer
}

/**
* @template {UCAN.DID} ConfigDID
* @template {UCAN.DID} [ID=UCAN.DID]
Copy link
Contributor

Choose a reason for hiding this comment

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

whats the difference here between these?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There is none. Great catch :) ty

Copy link
Contributor Author

Choose a reason for hiding this comment

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

* @template {UCAN.SigAlg} [Alg=UCAN.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
}
9 changes: 7 additions & 2 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,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.create({
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: I think ...uploadProvider(), ...storeProvider() would be more aligned with other stuff already in use. Also I think it would be a good idea to make fetch optional so we only need to pass it when overriding default.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

i agree and will do as fast follow #338

fetch: globalThis.fetch,
}),

voucher: {
claim: voucherClaimProvider(ctx),
redeem: voucherRedeemProvider(ctx),
Expand Down Expand Up @@ -97,7 +102,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
188 changes: 188 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,188 @@
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 { DID } from '@ucanto/core'
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'
Copy link
Contributor

Choose a reason for hiding this comment

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

can we please not do these type only imports, dag-ucan can be imported from ucanto interface and caps types can be imported inline from caps types export. Or just create a types.ts file

Copy link
Contributor Author

Choose a reason for hiding this comment

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

i got rid of them and will avoid it in the future too

// eslint-disable-next-line no-unused-vars
import * as Ucanto from '@ucanto/interface'
import { createProxyHandler } from '../ucanto/proxy.js'

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

Choose a reason for hiding this comment

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

should this just be Ucanto ServiceMethod ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The only difference is the defaults on Success and Failure. I don't need those so I removed this. Good find. I didn't know all the types we had at our disposal but after this PR I've definitely gotten more familiar with them.
fa1245e


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

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

/**
* @template {Record<string, any>} T
* @param {object} options
* @param {Ucanto.Signer} [options.signer]
* @param {Pick<Map<dagUcan.DID, Ucanto.ConnectionView<T>>, 'get'>} options.connections
Copy link
Contributor

Choose a reason for hiding this comment

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

what is this type? what is it picking ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

it was { get: (audience: Ucanto.UCAN.DID) => ConnectionView<T> }

I got rid of this in favor of a record/obj with no functions (which iirc Irakli had also preferred). 08981d8#diff-5fb7be1f94b33171518ab23f095f24d6e1e025282c99e8131c58e9db6c2caa4eL17

*/
function createProxyStoreService(options) {
const handleInvocation = createProxyHandler(options)
/**
* @type {StoreService}
*/
const store = {
add: handleInvocation,
list: handleInvocation,
remove: handleInvocation,
}
return store
}

/**
* @template {Record<string, any>} T
* @param {object} options
* @param {Ucanto.Signer} [options.signer]
* @param {Pick<Map<dagUcan.DID, Ucanto.ConnectionView<T>>, 'get'>} options.connections
*/
function createProxyUploadService(options) {
const handleInvocation = createProxyHandler(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
*/

/**
* @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,
}),
})
}

/**
* @type {Record<string, {
* audience: dagUcan.DID,
* url: URL,
* }>}
*/
const uploadApiEnvironments = {
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'),
},
}

/**
* @interface {Pick<Map<dagUcan.DID, Ucanto.Connection<any>>, 'get'>}
*/
const audienceConnections = {
audienceToUrl: (() => {
/** @type {{ [k: keyof typeof uploadApiEnvironments]: URL }} */
const object = {}
for (const [, { audience, url }] of Object.entries(uploadApiEnvironments)) {
object[audience] = url
}
return object
})(),
fetch: globalThis.fetch,
/** @type {undefined|Ucanto.DID} */
defaultAudience: uploadApiEnvironments.production.audience,
/**
* Return a ucanto connection to use for the provided invocation audience.
* If no connection is available for the provided audience, return a connection for the default audience.
*
* @param {dagUcan.DID} audience
*/
get(audience) {
const defaultedAudience =
audience in this.audienceToUrl ? audience : this.defaultAudience
if (!defaultedAudience) {
return
}
const url = this.audienceToUrl[defaultedAudience]
return createUcantoHttpConnection({
audience: defaultedAudience,
fetch: this.fetch,
url,
})
},
}

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

/**
* @param {object} options
* @param {Ucanto.Signer} [options.signer]
* @param {typeof globalThis.fetch} options.fetch
*/
static create(options) {
const proxyOptions = {
signer: options.signer,
connections: {
...audienceConnections,
...(options.fetch && { fetch: options.fetch }),
},
}
return new this(
createProxyStoreService(proxyOptions),
createProxyUploadService(proxyOptions)
)
}

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

Choose a reason for hiding this comment

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

having an hard time following this

cant we just to the following ? all the types should match and its easier to understand

/**
 * @param {object} options
 * @param {typeof globalThis.fetch} options.fetch
 */
function createUploadProxy(options) {
  /** @type {Record<Ucanto.DID, Ucanto.ConnectionView<import('@web3-storage/access/types').Service>>} */
  const connections = {
      'did:web:web3.storage': createUcantoHttpConnection({
        audience: 'did:web:web3.storage',
        url: new URL('https://up.web3.storage'),
        fetch: options.fetch
      }),
     'did:web:staging.web3.storage': createUcantoHttpConnection({
        audience: 'did:web:staging.web3.storage',
        url: new URL('https://staging.up.web3.storage'),
        fetch: options.fetch
      })
    }
  

  return {
    store: {
      add: Server.provide(Store.add, async ({capability, invocation}) => {
        const conn = connections[invocation.audience.did()]
        return conn.execute(invocation)
      })
    },
    .... // all the other handlers
  }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I iterated towards this in two ways:

  • connections is now just an object like you illustrate here no get method
  • unbundling the class with store + upload into separate functions that build each

I don't believe that the way you're doing conn.execute(invocation) will actually work. I wish it did. I tried that first. but had to try some various things to avoid making ucanto changes.

@Gozala and I discussed a few ways that ucanto could make this easier, but also decided to do something that works with ucanto as is, ship that, and decde after that whether to push some of those tools (that are now in access-api/src/ucanto/proxy.js) down into ucanto itself.

Copy link
Contributor

Choose a reason for hiding this comment

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

why does it not work ? this was the promise of "lets make a single endpoint because ucanto makes this super simple" this is not super simple

57 changes: 57 additions & 0 deletions packages/access-api/src/ucanto/proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// eslint-disable-next-line no-unused-vars
import * as Ucanto from '@ucanto/interface'
import * as Client from '@ucanto/client'

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

/**
* @param {object} options
* @param {Pick<Map<Ucanto.DID, Ucanto.ConnectionView<any>>, 'get'>} 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 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)
// @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: invocationIn.capabilities[0],
audience: invocationIn.audience,
proofs: [invocationIn],
}),
],
/** @type {Client.ConnectionView<any>} */ (connection)
)
return result
}
}
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
14 changes: 13 additions & 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 Expand Up @@ -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')
}
Loading