diff --git a/packages/access-api/package.json b/packages/access-api/package.json index 2549de000..08a3d9def 100644 --- a/packages/access-api/package.json +++ b/packages/access-api/package.json @@ -22,6 +22,7 @@ "@ucanto/principal": "^4.0.2", "@ucanto/server": "^4.0.2", "@ucanto/transport": "^4.0.2", + "@ucanto/validator": "^4.0.2", "@web3-storage/access": "workspace:^", "@web3-storage/capabilities": "workspace:^", "@web3-storage/worker-utils": "0.4.3-dev", diff --git a/packages/access-api/src/bindings.d.ts b/packages/access-api/src/bindings.d.ts index 50adbe61c..38c2de5d7 100644 --- a/packages/access-api/src/bindings.d.ts +++ b/packages/access-api/src/bindings.d.ts @@ -23,6 +23,11 @@ export interface Env { // vars ENV: string DEBUG: string + /** + * publicly advertised decentralized identifier of the running api service + * * this may be used to filter incoming ucanto invocations + */ + DID: string // secrets PRIVATE_KEY: string SENTRY_DSN: string diff --git a/packages/access-api/src/config.js b/packages/access-api/src/config.js index afa519ae2..65d0061ad 100644 --- a/packages/access-api/src/config.js +++ b/packages/access-api/src/config.js @@ -1,3 +1,6 @@ +import { DID } from '@ucanto/validator' +import { Signer } from '@ucanto/principal/ed25519' + /** * Loads configuration variables from the global environment and returns a JS object * keyed by variable names. @@ -29,11 +32,13 @@ export function loadConfig(env) { } } + const DID = env.DID + const PRIVATE_KEY = vars.PRIVATE_KEY + const signer = configureSigner({ DID, PRIVATE_KEY }) return { DEBUG: boolValue(vars.DEBUG), ENV: parseRuntimeEnv(vars.ENV), - PRIVATE_KEY: vars.PRIVATE_KEY, POSTMARK_TOKEN: vars.POSTMARK_TOKEN, SENTRY_DSN: vars.SENTRY_DSN, LOGTAIL_TOKEN: vars.LOGTAIL_TOKEN, @@ -49,6 +54,8 @@ export function loadConfig(env) { // eslint-disable-next-line no-undef COMMITHASH: ACCOUNT_COMMITHASH, + signer, + // bindings METRICS: /** @type {import("./bindings").AnalyticsEngine} */ ( @@ -105,3 +112,19 @@ export function createAnalyticsEngine() { _store: store, } } + +/** + * Given a config, return a ucanto Signer object representing the service + * + * @param {object} config + * @param {string} [config.DID] - public identifier of the running service. e.g. a did:key or a did:web + * @param {string} config.PRIVATE_KEY - multiformats private key of primary signing key + */ +export function configureSigner(config) { + const signer = Signer.parse(config.PRIVATE_KEY) + const did = config.DID + if (!did) { + return signer + } + return signer.withDID(DID.match({}).from(did)) +} diff --git a/packages/access-api/src/utils/context.js b/packages/access-api/src/utils/context.js index 747889c57..d9e5c9490 100644 --- a/packages/access-api/src/utils/context.js +++ b/packages/access-api/src/utils/context.js @@ -1,4 +1,3 @@ -import { Signer } from '@ucanto/principal/ed25519' import { Logging } from '@web3-storage/worker-utils/logging' import Toucan from 'toucan-js' import pkg from '../../package.json' @@ -41,13 +40,11 @@ export function getContext(request, env, ctx) { commit: config.COMMITHASH, env: config.ENV, }) - - const keypair = Signer.parse(config.PRIVATE_KEY) const url = new URL(request.url) const db = new D1QB(config.DB) return { log, - signer: keypair, + signer: config.signer, config, url, kvs: { diff --git a/packages/access-api/test/config.test.js b/packages/access-api/test/config.test.js new file mode 100644 index 000000000..8707c8134 --- /dev/null +++ b/packages/access-api/test/config.test.js @@ -0,0 +1,50 @@ +import assert from 'assert' +import * as configModule from '../src/config.js' + +/** keypair that can be used for testing */ +const testKeypair = { + private: { + /** + * Private key encoded as multiformats + */ + multiformats: + 'MgCYWjE6vp0cn3amPan2xPO+f6EZ3I+KwuN1w2vx57vpJ9O0Bn4ci4jn8itwc121ujm7lDHkCW24LuKfZwIdmsifVysY=', + }, + public: { + /** + * Public key encoded as a did:key + */ + did: 'did:key:z6MkqBzPG7oNu7At8fktasQuS7QR7Tj7CujaijPMAgzdmAxD', + }, +} + +describe('@web3-storage/access-api/src/config configureSigner', () => { + it('creates a signer using config.{DID,PRIVATE_KEY}', async () => { + const config = { + PRIVATE_KEY: testKeypair.private.multiformats, + DID: 'did:web:exampe.com', + } + const signer = configModule.configureSigner(config) + assert.ok(signer) + assert.equal(signer.did().toString(), config.DID) + const { keys } = signer.toArchive() + const didKeys = Object.keys(keys) + assert.deepEqual(didKeys, [testKeypair.public.did]) + }) + it('errors if config.DID is provided but not a did', () => { + assert.throws(() => { + configModule.configureSigner({ + DID: 'not a did', + PRIVATE_KEY: testKeypair.private.multiformats, + }) + }, 'Invalid DID') + }) + it('infers did from config.PRIVATE_KEY when config.DID is omitted', async () => { + const config = { + PRIVATE_KEY: testKeypair.private.multiformats, + } + const signer = configModule.configureSigner(config) + assert.ok(signer) + assert.equal(signer.did().toString(), testKeypair.public.did) + }) +}) diff --git a/packages/access-api/test/helpers/context.js b/packages/access-api/test/helpers/context.js index b59b4f073..fb6ae1465 100644 --- a/packages/access-api/test/helpers/context.js +++ b/packages/access-api/test/helpers/context.js @@ -14,20 +14,46 @@ dotenv.config({ path: path.join(__dirname, '..', '..', '..', '..', '.env.tpl'), }) -export const bindings = { - ENV: 'test', - DEBUG: 'false', - PRIVATE_KEY: process.env.PRIVATE_KEY || '', - POSTMARK_TOKEN: process.env.POSTMARK_TOKEN || '', - SENTRY_DSN: process.env.SENTRY_DSN || '', - LOGTAIL_TOKEN: process.env.LOGTAIL_TOKEN || '', - W3ACCESS_METRICS: createAnalyticsEngine(), +/** + * @typedef {Omit} AccessApiBindings - bindings object expected by access-api workers + */ + +/** + * Given a map of environment vars, return a map of bindings that can be passed with access-api worker invocations. + * + * @param {{ [key: string]: string | undefined }} env - environment variables + * @returns {AccessApiBindings} - env bindings expected by access-api worker objects + */ +function createBindings(env) { + return { + ENV: 'test', + DEBUG: 'false', + DID: env.DID || '', + PRIVATE_KEY: env.PRIVATE_KEY || '', + POSTMARK_TOKEN: env.POSTMARK_TOKEN || '', + SENTRY_DSN: env.SENTRY_DSN || '', + LOGTAIL_TOKEN: env.LOGTAIL_TOKEN || '', + W3ACCESS_METRICS: createAnalyticsEngine(), + } } +/** + * Good default bindings useful for tests - configured via process.env + */ +export const bindings = createBindings(process.env) + export const serviceAuthority = Signer.parse(bindings.PRIVATE_KEY) -export async function context() { +/** + * @param {object} [options] + * @param {Record} options.environment - environment variables to use when configuring access-api. Defaults to process.env. + */ +export async function context(options) { + const environment = options?.environment || process.env const principal = await Signer.generate() + const bindings = createBindings({ + ...environment, + }) const mf = new Miniflare({ packagePath: true, wranglerConfigPath: true, diff --git a/packages/access-api/test/ucan.test.js b/packages/access-api/test/ucan.test.js index 09d7a9dc8..b16265d00 100644 --- a/packages/access-api/test/ucan.test.js +++ b/packages/access-api/test/ucan.test.js @@ -144,6 +144,29 @@ describe('ucan', function () { t.deepEqual(rsp, ['test pass']) }) + test('should support ucan invoking to a did:web aud', async function () { + const serviceDidWeb = 'did:web:example.com' + const { mf, issuer, service } = await context({ + environment: { + ...process.env, + DID: serviceDidWeb, + }, + }) + const ucan = await UCAN.issue({ + issuer, + audience: service.withDID(serviceDidWeb), + capabilities: [{ can: 'testing/pass', with: 'mailto:admin@dag.house' }], + }) + const res = await mf.dispatchFetch('http://localhost:8787/raw', { + method: 'POST', + headers: { + Authorization: `Bearer ${UCAN.format(ucan)}`, + }, + }) + const rsp = await res.json() + t.deepEqual(rsp, ['test pass']) + }) + test('should handle exception in route handler', async function () { const { mf, service, issuer } = ctx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bb3c1cb5..4e21e1dad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,6 +40,7 @@ importers: '@ucanto/principal': ^4.0.2 '@ucanto/server': ^4.0.2 '@ucanto/transport': ^4.0.2 + '@ucanto/validator': ^4.0.2 '@web3-storage/access': workspace:^ '@web3-storage/capabilities': workspace:^ '@web3-storage/worker-utils': 0.4.3-dev @@ -71,6 +72,7 @@ importers: '@ucanto/principal': 4.0.2 '@ucanto/server': 4.0.2 '@ucanto/transport': 4.0.2 + '@ucanto/validator': 4.0.2 '@web3-storage/access': link:../access-client '@web3-storage/capabilities': link:../capabilities '@web3-storage/worker-utils': 0.4.3-dev