diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a45a7e..b86cfca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # bedrock-profile-http ChangeLog +## 26.1.0 - 2025-mm-dd + +### Added +- Add optional `interactions` feature, allowing an account to create + VC exchanges based on predefined workflows. Currently, these + exchanges do not store any VCs that might be received in any + particular profile's storage, however, this might change in the + future. + ## 26.0.0 - 2025-03-08 ### Changed diff --git a/lib/config.js b/lib/config.js index b477291..0915c33 100644 --- a/lib/config.js +++ b/lib/config.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. */ import * as bedrock from '@bedrock/core'; @@ -47,3 +47,23 @@ const meterServiceName = `${namespace}.meterService`; cc(`${meterServiceName}.url`, () => `${bedrock.config.server.baseUri}/meters`); // ensure meter service config is overridden in deployments config.ensureConfigOverride.fields.push(meterServiceName); + +// optional interactions config +cfg.interactions = { + // FIXME: add qr-code route fallback for non-accept-json "protocols" requests + enabled: false, + // types of interactions, type name => definition + /* Spec: + { + : { + ..., + // a unique local interaction ID for use in interaction URLs + localInteractionId, + zcaps: { + readWriteExchanges: + } + } + } + */ + types: {} +}; diff --git a/lib/http.js b/lib/http.js index 8ec4251..4995bc5 100644 --- a/lib/http.js +++ b/lib/http.js @@ -1,36 +1,30 @@ /*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. */ import * as bedrock from '@bedrock/core'; import * as schemas from '../schemas/bedrock-profile-http.js'; import {profileAgents, profileMeters, profiles} from '@bedrock/profile'; import {asyncHandler} from '@bedrock/express'; -import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020'; import {ensureAuthenticated} from '@bedrock/passport'; import {getAppIdentity} from '@bedrock/app-identity'; -import {httpsAgent} from '@bedrock/https-agent'; import {createValidateMiddleware as validate} from '@bedrock/validation'; -import {ZcapClient} from '@digitalbazaar/ezcap'; +import {ZCAP_CLIENT} from './zcapClient.js'; + +// include interactions routes +import './interactions.js'; const {config, util: {BedrockError}} = bedrock; let APP_ID; let EDV_METER_CREATION_ZCAP; let WEBKMS_METER_CREATION_ZCAP; -let ZCAP_CLIENT; bedrock.events.on('bedrock.init', () => { - // create signer using the application's capability invocation key - const {id, keys: {capabilityInvocationKey}} = getAppIdentity(); + const {id} = getAppIdentity(); APP_ID = id; - ZCAP_CLIENT = new ZcapClient({ - agent: httpsAgent, - invocationSigner: capabilityInvocationKey.signer(), - SuiteClass: Ed25519Signature2020 - }); - const cfg = bedrock.config['profile-http']; + const {edvMeterCreationZcap, webKmsMeterCreationZcap} = cfg; if(edvMeterCreationZcap) { EDV_METER_CREATION_ZCAP = JSON.parse(edvMeterCreationZcap); @@ -48,8 +42,8 @@ bedrock.events.on('bedrock-express.configure.routes', app => { const profileAgentPath = `${profileAgentsPath}/:profileAgentId`; const routes = { profiles: basePath, - profileAgents: `${profileAgentsPath}`, - profileAgent: `${profileAgentPath}`, + profileAgents: profileAgentsPath, + profileAgent: profileAgentPath, profileAgentClaim: `${profileAgentPath}/claim`, profileAgentCapabilities: `${profileAgentPath}/capabilities/delegate`, profileAgentCapabilitySet: `${profileAgentPath}/capability-set` diff --git a/lib/interactions.js b/lib/interactions.js new file mode 100644 index 0000000..d79e578 --- /dev/null +++ b/lib/interactions.js @@ -0,0 +1,246 @@ +/*! + * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. + */ +import * as bedrock from '@bedrock/core'; +import * as schemas from '../schemas/bedrock-profile-http.js'; +import {poll, pollers, push} from '@bedrock/notify'; +import {agent} from '@bedrock/https-agent'; +import {asyncHandler} from '@bedrock/express'; +import {ensureAuthenticated} from '@bedrock/passport'; +import {httpClient} from '@digitalbazaar/http-client'; +import {createValidateMiddleware as validate} from '@bedrock/validation'; +import {ZCAP_CLIENT as zcapClient} from './zcapClient.js'; + +const {config, util: {BedrockError}} = bedrock; + +let DEFINITIONS_BY_TYPE_MAP; +let DEFINITIONS_BY_ID_MAP; + +// use a TTL of 1 second to account for the case where a push notification +// isn't received by the same instance that the client hits, but prevent +// requests from triggering a hit to the workflow service backend more +// frequently than 1 second +const POLL_TTL = 1000; + +bedrock.events.on('bedrock.init', () => { + processInteractionConfig(); +}); + +bedrock.events.on('bedrock-express.configure.routes', app => { + const interactionsPath = '/interactions'; + const routes = { + interactions: interactionsPath, + interaction: `${interactionsPath}/:localInteractionId/:localExchangeId`, + callback: `${interactionsPath}/:localInteractionId/callbacks/:pushToken` + }; + + // base URL for server + const {baseUri} = bedrock.config.server; + + // create an interaction to exchange VCs + app.post( + routes.interactions, + ensureAuthenticated, + validate({bodySchema: schemas.createInteraction}), + asyncHandler(async (req, res) => { + const {id: accountId} = req.user.account || {}; + const {type, exchange: {variables}} = req.body; + + const definition = DEFINITIONS_BY_TYPE_MAP?.get(type); + if(!definition) { + throw new BedrockError(`Interaction type "${type}" not found.`, { + name: 'NotFoundError', + details: { + httpStatusCode: 404, + public: true + } + }); + } + + // create a push token + const {token} = await push.createPushToken({event: 'exchangeUpdated'}); + + // compute callback URL + const {localInteractionId} = definition; + const pushCallbackUrl = + `${baseUri}${interactionsPath}/${localInteractionId}` + + `/callbacks/${token}`; + + // create exchange with given variables + const exchange = { + // FIXME: use `expires` instead of now-deprecated `ttl` + // 15 minute expiry in seconds + ttl: 60 * 15, + // template variables + variables: { + ...variables, + pushCallbackUrl, + accountId + } + }; + const capability = definition.zcaps.get('readWriteExchanges'); + const response = await zcapClient.write({json: exchange, capability}); + const exchangeId = response.headers.get('location'); + // reuse `localExchangeId` in path + const localExchangeId = exchangeId.slice(exchangeId.lastIndexOf('/') + 1); + const id = `${config.server.baseUri}${routes.interactions}/` + + `${localInteractionId}/${localExchangeId}`; + res.json({interactionId: id, exchangeId}); + })); + + // gets an interaction + app.get( + routes.interaction, + ensureAuthenticated, + validate({querySchema: schemas.getInteractionQuery}), + asyncHandler(async (req, res) => { + const {id: accountId} = req.user.account || {}; + const { + params: {localInteractionId, localExchangeId}, + query: {iuv} + } = req; + + // get interaction definition + const definition = _getInteractionDefinition({localInteractionId}); + + // determine full exchange ID based on related capability + const capability = definition.zcaps.get('readWriteExchanges'); + const exchangeId = `${capability.invocationTarget}/${localExchangeId}`; + + // if an "Interaction URL Version" is present send "protocols" + // (note: validation requires it to be `1`, so no need to check its value) + if(iuv) { + // FIXME: send to a QR-code page if supported + // FIXME: check config for supported QR code route and use it + // instead of hard-coded value + if(req.accepts('html') || !req.accepts('json')) { + return res.redirect(`${req.originalUrl}/qr-code`); + } + try { + const url = `${exchangeId}/protocols`; + const {data: protocols} = await httpClient.get(url, {agent}); + res.json(protocols); + } catch(cause) { + throw new BedrockError( + 'Unable to serve protocols object: ' + cause.message, { + name: 'OperationError', + details: {httpStatusCode: 500, public: true}, + cause + }); + } + } + + // poll the exchange... + const result = await poll({ + id: exchangeId, + poller: _createExchangePoller({accountId, capability}), + ttl: POLL_TTL + }); + + res.json(result.value); + })); + + // push event handler + app.post( + routes.callback, + push.createVerifyPushTokenMiddleware({event: 'exchangeUpdated'}), + asyncHandler(async (req, res) => { + const {event: {data: {exchangeId: id}}} = req.body; + const {localInteractionId} = req.params; + + // get interaction definition + const definition = _getInteractionDefinition({localInteractionId}); + + // get capability for fetching exchange and verify its invocation target + // matches the exchange ID passed + const capability = definition.zcaps.get('readWriteExchanges'); + if(!id.startsWith(capability.invocationTarget)) { + throw new BedrockError('Not authorized.', { + name: 'NotAllowedError', + details: {httpStatusCode: 403, public: true} + }); + } + + // poll (and clear cache) + await poll({ + id, + poller: _createExchangePoller({capability}), + ttl: POLL_TTL, + useCache: false + }); + res.sendStatus(204); + })); +}); + +export function processInteractionConfig() { + const cfg = config['profile-http']; + + // interactions feature is optional, return early if not enabled + if(!cfg.interactions?.enabled) { + return; + } + + // parse interaction types when enabled + const {types = {}} = cfg.interactions; + DEFINITIONS_BY_TYPE_MAP = new Map(); + DEFINITIONS_BY_ID_MAP = new Map(); + for(const typeName in types) { + const {localInteractionId, zcaps} = types[typeName]; + if(!localInteractionId) { + throw new TypeError( + '"bedrock.config.profile-http.interaction.types" must each ' + + 'have "localInteractionId".'); + } + const definition = { + name: typeName, + localInteractionId, + zcaps: new Map() + }; + for(const zcapName in zcaps) { + const zcap = zcaps[zcapName]; + definition.zcaps.set(zcapName, JSON.parse(zcap)); + } + if(!definition.zcaps.has('readWriteExchanges')) { + throw new TypeError( + '"bedrock.config.profile-http.interaction.types" must each ' + + 'have "zcaps.readWriteExchanges".'); + } + DEFINITIONS_BY_TYPE_MAP.set(typeName, definition); + DEFINITIONS_BY_ID_MAP.set(localInteractionId, definition); + } +} + +function _createExchangePoller({accountId, capability}) { + return pollers.createExchangePoller({ + zcapClient, + capability, + filterExchange({exchange/*, previousPollResult*/}) { + // if `accountId` given, ensure it matches exchange variables + if(accountId && exchange?.variables.accountId !== accountId) { + throw new BedrockError('Not authorized.', { + name: 'NotAllowedError', + details: {httpStatusCode: 403, public: true} + }); + } + // return only information that should be accessible to client + return { + exchange: { + state: exchange.state, + result: exchange.variables.results?.finish + } + }; + } + }); +} + +function _getInteractionDefinition({localInteractionId}) { + const definition = DEFINITIONS_BY_ID_MAP?.get(localInteractionId); + if(!definition) { + throw new BedrockError( + `Interaction type for "${localInteractionId}" not found.`, { + name: 'NotFoundError', + details: {httpStatusCode: 404, public: true} + }); + } + return definition; +} diff --git a/lib/zcapClient.js b/lib/zcapClient.js new file mode 100644 index 0000000..f0087e0 --- /dev/null +++ b/lib/zcapClient.js @@ -0,0 +1,21 @@ +/*! + * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. + */ +import * as bedrock from '@bedrock/core'; +import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020'; +import {getAppIdentity} from '@bedrock/app-identity'; +import {httpsAgent} from '@bedrock/https-agent'; +import {ZcapClient} from '@digitalbazaar/ezcap'; + +export let ZCAP_CLIENT; + +bedrock.events.on('bedrock.init', () => { + // create signer using the application's capability invocation key + const {keys: {capabilityInvocationKey}} = getAppIdentity(); + + ZCAP_CLIENT = new ZcapClient({ + agent: httpsAgent, + invocationSigner: capabilityInvocationKey.signer(), + SuiteClass: Ed25519Signature2020 + }); +}); diff --git a/package.json b/package.json index 9462a52..4eee0b9 100644 --- a/package.json +++ b/package.json @@ -30,13 +30,15 @@ "homepage": "https://github.com/digitalbazaar/bedrock-profile-http", "dependencies": { "@digitalbazaar/ed25519-signature-2020": "^5.4.0", - "@digitalbazaar/ezcap": "^4.1.0" + "@digitalbazaar/ezcap": "^4.1.0", + "@digitalbazaar/http-client": "^4.2.0" }, "peerDependencies": { "@bedrock/app-identity": "^4.0.0", "@bedrock/core": "^6.3.0", "@bedrock/express": "^8.3.1", "@bedrock/https-agent": "^4.1.0", + "@bedrock/notify": "^1.1.0", "@bedrock/passport": "^12.0.0", "@bedrock/profile": "^26.0.0", "@bedrock/validation": "^7.1.1" diff --git a/schemas/bedrock-profile-http.js b/schemas/bedrock-profile-http.js index e408684..6e350c3 100644 --- a/schemas/bedrock-profile-http.js +++ b/schemas/bedrock-profile-http.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. */ const account = { title: 'Account', @@ -162,10 +162,60 @@ const delegateCapability = { } }; +const createInteraction = { + title: 'Create Interaction', + type: 'object', + required: ['type', 'exchange'], + additionalProperties: false, + properties: { + type: { + type: 'string' + }, + exchange: { + type: 'object', + required: ['variables'], + variables: { + type: 'object', + additionalProperties: false, + oneOf: [{ + required: ['verifiablePresentation'] + }, { + required: ['verifiablePresentationRequest'] + }], + properties: { + allowUnprotectedPresentation: { + type: 'boolean' + }, + verifiablePresentation: { + type: 'object' + }, + verifiablePresentationRequest: { + type: 'object' + } + } + } + } + } +}; + +const getInteractionQuery = { + title: 'Interaction Query', + type: 'object', + additionalProperties: false, + properties: { + iuv: { + title: 'Interaction URL version', + const: '1' + } + } +}; + export { profileAgent, profileAgents, accountQuery, delegateCapability, + createInteraction, + getInteractionQuery, zcaps }; diff --git a/test/mocha/10-profiles.js b/test/mocha/10-profiles.js new file mode 100644 index 0000000..42b275a --- /dev/null +++ b/test/mocha/10-profiles.js @@ -0,0 +1,161 @@ +/*! + * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. + */ +import * as helpers from './helpers.js'; +import {config} from '@bedrock/core'; +// apisauce is a wrapper around axios that provides improved error handling +import {create} from 'apisauce'; +import https from 'node:https'; +import {mockData} from './mock.data.js'; + +let accounts; +let api; + +const baseURL = `https://${config.server.host}`; + +describe('profiles', () => { + // mock session authentication for delegations endpoint + let passportStub; + before(async () => { + await helpers.prepareDatabase(mockData); + passportStub = helpers.stubPassport(); + accounts = mockData.accounts; + api = create({ + baseURL, + headers: {Accept: 'application/ld+json, application/json'}, + httpsAgent: new https.Agent({rejectUnauthorized: false}) + }); + }); + after(async () => { + passportStub.restore(); + }); + + describe('POST /profiles (create a new profile)', () => { + afterEach(async () => { + await helpers.removeCollections(['profile-profileAgent']); + }); + it('successfully create a new profile', async () => { + const {account: {id: account}} = accounts['alpha@example.com']; + const didMethod = 'v1'; + let result; + let error; + try { + result = await api.post('/profiles', {account, didMethod}); + } catch(e) { + error = e; + } + assertNoError(error); + should.exist(result); + result.status.should.equal(200); + result.ok.should.equal(true); + result.data.id.should.be.a('string'); + result.data.id.startsWith('did:v1').should.equal(true); + + const {meters, id: profileId} = result.data; + _shouldHaveMeters({meters, profileId}); + }); + it('create a new profile with didMethod and didOptions', async () => { + const {account: {id: account}} = accounts['alpha@example.com']; + const didMethod = 'v1'; + const didOptions = {mode: 'test'}; + let result; + let error; + try { + result = await api.post('/profiles', {account, didMethod, didOptions}); + } catch(e) { + error = e; + } + assertNoError(error); + should.exist(result); + result.status.should.equal(200); + result.ok.should.equal(true); + result.data.id.should.be.a('string'); + result.data.id.startsWith('did:v1').should.equal(true); + + const {meters, id: profileId} = result.data; + _shouldHaveMeters({meters, profileId}); + }); + it('create a new profile with didMethods "v1" and "key"', async () => { + const {account: {id: account}} = accounts['alpha@example.com']; + const didMethods = ['v1', 'key']; + const didOptions = {mode: 'test'}; + + for(const didMethod of didMethods) { + let result; + let error; + try { + result = await api.post('/profiles', + {account, didMethod, didOptions}); + } catch(e) { + error = e; + } + assertNoError(error); + should.exist(result); + result.status.should.equal(200); + result.ok.should.equal(true); + result.data.id.should.be.a('string'); + + const {meters, id: profileId} = result.data; + _shouldHaveMeters({meters, profileId}); + } + }); + it('throws error when didMethod is not "v1" or "key"', async () => { + const {account: {id: account}} = accounts['alpha@example.com']; + const didMethod = 'not-v1-or-key'; + const didOptions = {mode: 'test'}; + + const result = await api.post('/profiles', + {account, didMethod, didOptions}); + + should.exist(result); + result.status.should.equal(500); + }); + it('throws error when there is no account', async () => { + let account; + let result; + let error; + try { + result = await api.post('/profiles', {account}); + } catch(e) { + error = e; + } + assertNoError(error); + should.exist(result); + result.status.should.equal(400); + result.ok.should.equal(false); + result.data.message.should.equal( + 'A validation error occurred in the \'Account Query\' validator.'); + }); + it('throws error when account is not authorized', async () => { + let result; + let error; + try { + result = await api.post('/profiles', {account: '123'}); + } catch(e) { + error = e; + } + assertNoError(error); + should.exist(result); + result.status.should.equal(403); + result.ok.should.equal(false); + result.data.message.should.equal('The "account" is not authorized.'); + }); + }); // end create a new profile +}); + +function _shouldHaveMeters({meters, profileId}) { + meters.should.be.an('array'); + meters.should.have.length(2); + const {meter: edvMeter} = meters.find( + m => m.meter.serviceType === 'edv'); + edvMeter.id.should.be.a('string'); + edvMeter.profile.should.equal(profileId); + edvMeter.serviceType.should.equal('edv'); + edvMeter.referenceId.should.equal('profile:core:edv'); + const {meter: kmsMeter} = meters.find( + m => m.meter.serviceType === 'webkms'); + kmsMeter.id.should.be.a('string'); + kmsMeter.profile.should.equal(profileId); + kmsMeter.serviceType.should.equal('webkms'); + kmsMeter.referenceId.should.equal('profile:core:webkms'); +} diff --git a/test/mocha/10-api.js b/test/mocha/20-profileAgents.js similarity index 87% rename from test/mocha/10-api.js rename to test/mocha/20-profileAgents.js index ca656de..a0d4319 100644 --- a/test/mocha/10-api.js +++ b/test/mocha/20-profileAgents.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. */ import * as helpers from './helpers.js'; import {config} from '@bedrock/core'; @@ -14,7 +14,7 @@ let api; const baseURL = `https://${config.server.host}`; -describe('bedrock-profile-http', () => { +describe('profile agents', () => { // mock session authentication for delegations endpoint let passportStub; before(async () => { @@ -32,118 +32,6 @@ describe('bedrock-profile-http', () => { passportStub.restore(); }); - describe('POST /profiles (create a new profile)', () => { - afterEach(async () => { - await helpers.removeCollections(['profile-profileAgent']); - }); - it('successfully create a new profile', async () => { - const {account: {id: account}} = accounts['alpha@example.com']; - const didMethod = 'v1'; - let result; - let error; - try { - result = await api.post('/profiles', {account, didMethod}); - } catch(e) { - error = e; - } - assertNoError(error); - should.exist(result); - result.status.should.equal(200); - result.ok.should.equal(true); - result.data.id.should.be.a('string'); - result.data.id.startsWith('did:v1').should.equal(true); - - const {meters, id: profileId} = result.data; - _shouldHaveMeters({meters, profileId}); - }); - it('create a new profile with didMethod and didOptions', async () => { - const {account: {id: account}} = accounts['alpha@example.com']; - const didMethod = 'v1'; - const didOptions = {mode: 'test'}; - let result; - let error; - try { - result = await api.post('/profiles', {account, didMethod, didOptions}); - } catch(e) { - error = e; - } - assertNoError(error); - should.exist(result); - result.status.should.equal(200); - result.ok.should.equal(true); - result.data.id.should.be.a('string'); - result.data.id.startsWith('did:v1').should.equal(true); - - const {meters, id: profileId} = result.data; - _shouldHaveMeters({meters, profileId}); - }); - it('create a new profile with didMethods "v1" and "key"', async () => { - const {account: {id: account}} = accounts['alpha@example.com']; - const didMethods = ['v1', 'key']; - const didOptions = {mode: 'test'}; - - for(const didMethod of didMethods) { - let result; - let error; - try { - result = await api.post('/profiles', - {account, didMethod, didOptions}); - } catch(e) { - error = e; - } - assertNoError(error); - should.exist(result); - result.status.should.equal(200); - result.ok.should.equal(true); - result.data.id.should.be.a('string'); - - const {meters, id: profileId} = result.data; - _shouldHaveMeters({meters, profileId}); - } - }); - it('throws error when didMethod is not "v1" or "key"', async () => { - const {account: {id: account}} = accounts['alpha@example.com']; - const didMethod = 'not-v1-or-key'; - const didOptions = {mode: 'test'}; - - const result = await api.post('/profiles', - {account, didMethod, didOptions}); - - should.exist(result); - result.status.should.equal(500); - }); - it('throws error when there is no account', async () => { - let account; - let result; - let error; - try { - result = await api.post('/profiles', {account}); - } catch(e) { - error = e; - } - assertNoError(error); - should.exist(result); - result.status.should.equal(400); - result.ok.should.equal(false); - result.data.message.should.equal( - 'A validation error occurred in the \'Account Query\' validator.'); - }); - it('throws error when account is not authorized', async () => { - let result; - let error; - try { - result = await api.post('/profiles', {account: '123'}); - } catch(e) { - error = e; - } - assertNoError(error); - should.exist(result); - result.status.should.equal(403); - result.ok.should.equal(false); - result.data.message.should.equal('The "account" is not authorized.'); - }); - }); // end create a new profile - describe('POST /profile-agents (create a new profile agent)', () => { afterEach(async () => { await helpers.removeCollections(['profile-profileAgent']); @@ -847,7 +735,7 @@ describe('bedrock-profile-http', () => { result.data.type.should.equal('NotAllowedError'); }); }); -}); // end bedrock-profile-http +}); async function _createNProfiles({n, api, account, didMethod}) { const promises = []; diff --git a/test/mocha/30-interactions.js b/test/mocha/30-interactions.js new file mode 100644 index 0000000..b82c517 --- /dev/null +++ b/test/mocha/30-interactions.js @@ -0,0 +1,204 @@ +/*! + * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. + */ +import * as helpers from './helpers.js'; +import {config} from '@bedrock/core'; +// apisauce is a wrapper around axios that provides improved error handling +import {create} from 'apisauce'; +import https from 'node:https'; +import {mockData} from './mock.data.js'; + +let api; + +const baseURL = `https://${config.server.host}`; + +describe('interactions', () => { + // mock session authentication for delegations endpoint + let passportStub; + before(async () => { + await helpers.prepareDatabase(mockData); + passportStub = helpers.stubPassport(); + api = create({ + baseURL, + headers: {Accept: 'application/ld+json, application/json'}, + httpsAgent: new https.Agent({rejectUnauthorized: false}) + }); + }); + after(async () => { + passportStub.restore(); + }); + + it('fails to create a new interaction with bad post data', async () => { + let result; + let error; + try { + result = await api.post('/interactions', {}); + } catch(e) { + error = e; + } + assertNoError(error); + should.exist(result); + result.status.should.equal(400); + result.ok.should.equal(false); + result.data.message.should.equal( + `A validation error occurred in the 'Create Interaction' validator.`); + }); + + it('fails to create a new interaction with unknown type', async () => { + let result; + let error; + try { + result = await api.post('/interactions', { + type: 'does-not-exist', + exchange: { + variables: {} + } + }); + } catch(e) { + error = e; + } + assertNoError(error); + should.exist(result); + result.status.should.equal(404); + result.ok.should.equal(false); + result.data.name.should.equal('NotFoundError'); + result.data.message.should.equal( + 'Interaction type "does-not-exist" not found.'); + }); + + it('creates a new interaction', async () => { + let interactionId; + let exchangeId; + { + let result; + let error; + try { + result = await api.post('/interactions', { + type: 'test', + exchange: { + variables: {} + } + }); + } catch(e) { + error = e; + } + assertNoError(error); + should.exist(result); + result.status.should.equal(200); + result.ok.should.equal(true); + should.exist(result.data.interactionId); + should.exist(result.data.exchangeId); + interactionId = result.data.interactionId; + exchangeId = result.data.exchangeId; + } + + // get status of interaction + { + let result; + let error; + try { + result = await api.get(interactionId); + } catch(e) { + error = e; + } + assertNoError(error); + should.exist(result); + result.status.should.equal(200); + result.ok.should.equal(true); + should.exist(result.data.exchange); + result.data.exchange.should.include.keys(['state']); + result.data.exchange.state.should.equal('pending'); + } + + // get protocols for interaction + { + let result; + let error; + try { + result = await api.get(`${interactionId}?iuv=1`); + } catch(e) { + error = e; + } + assertNoError(error); + should.exist(result); + result.status.should.equal(200); + result.ok.should.equal(true); + should.exist(result.data.protocols); + result.data.protocols.should.include.keys(['inviteRequest']); + result.data.protocols.inviteRequest.should.equal( + `${exchangeId}/invite-request/response`); + } + }); + + it('completes an interaction', async () => { + let interactionId; + { + let result; + let error; + try { + result = await api.post('/interactions', { + type: 'test', + exchange: { + variables: {} + } + }); + } catch(e) { + error = e; + } + assertNoError(error); + interactionId = result.data.interactionId; + } + + // get protocols for interaction + let inviteRequestUrl; + { + let result; + let error; + try { + result = await api.get(`${interactionId}?iuv=1`); + } catch(e) { + error = e; + } + assertNoError(error); + inviteRequestUrl = result.data.protocols.inviteRequest; + } + + // create invite response for exchange + const referenceId = crypto.randomUUID(); + const inviteResponse = { + url: 'https://retailer.example/checkout/baskets/1', + purpose: 'checkout', + referenceId + }; + + // complete interaction by posting invite response to exchange + { + const response = await api.post(inviteRequestUrl, inviteResponse); + should.exist(response?.data?.referenceId); + // ensure `referenceId` matches + response.data.referenceId.should.equal(referenceId); + } + + // get status of interaction (exchange should have a `complete` state + // and result) + { + let result; + let error; + try { + result = await api.get(interactionId); + } catch(e) { + error = e; + } + assertNoError(error); + should.exist(result); + result.status.should.equal(200); + result.ok.should.equal(true); + should.exist(result.data.exchange); + result.data.exchange.should.include.keys(['state', 'result']); + result.data.exchange.state.should.equal('complete'); + should.exist(result.data.exchange.result.inviteRequest?.inviteResponse); + result.data.exchange.result.inviteRequest.inviteResponse + .should.deep.equal(inviteResponse); + } + }); +}); diff --git a/test/mocha/helpers.js b/test/mocha/helpers.js index 7530ff3..8a1f828 100644 --- a/test/mocha/helpers.js +++ b/test/mocha/helpers.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. */ import * as brAccount from '@bedrock/account'; import * as database from '@bedrock/mongodb'; diff --git a/test/mocha/mock.data.js b/test/mocha/mock.data.js index 11f7988..c89b360 100644 --- a/test/mocha/mock.data.js +++ b/test/mocha/mock.data.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. */ import {v4 as uuid} from 'uuid'; import {constants as zcapConstants} from '@digitalbazaar/zcap'; @@ -15,7 +15,10 @@ mockData.productIdMap = new Map([ ['urn:uuid:80a82316-e8c2-11eb-9570-10bf48838a41', 'webkms'], // edv service ['edv', 'urn:uuid:dbd15f08-ff67-11eb-893b-10bf48838a41'], - ['urn:uuid:dbd15f08-ff67-11eb-893b-10bf48838a41', 'edv'] + ['urn:uuid:dbd15f08-ff67-11eb-893b-10bf48838a41', 'edv'], + // workflow service + ['vc-workflow', 'urn:uuid:146b6a5b-eade-4612-a215-1f3b5f03d648'], + ['urn:uuid:146b6a5b-eade-4612-a215-1f3b5f03d648', 'vc-workflow'] ]); const zcaps = mockData.zcaps = {}; diff --git a/test/package.json b/test/package.json index 6aecb39..d110768 100644 --- a/test/package.json +++ b/test/package.json @@ -27,18 +27,27 @@ "@bedrock/meter-http": "^14.0.0", "@bedrock/meter-usage-reporter": "^10.0.0", "@bedrock/mongodb": "^11.0.0", + "@bedrock/notify": "^1.1.0", + "@bedrock/oauth2-verifier": "^2.4.0", "@bedrock/package-manager": "^3.0.0", "@bedrock/passport": "^12.0.0", "@bedrock/profile": "^26.0.0", "@bedrock/profile-http": "file:..", "@bedrock/security-context": "^9.0.0", "@bedrock/server": "^5.1.0", + "@bedrock/service-agent": "^10.2.0", + "@bedrock/service-core": "^11.2.1", "@bedrock/ssm-mongodb": "^13.0.0", "@bedrock/test": "^8.2.0", "@bedrock/validation": "^7.1.1", + "@bedrock/vc-delivery": "^7.7.1", + "@bedrock/vc-verifier": "^22.1.0", "@bedrock/veres-one-context": "^16.0.0", "@bedrock/zcap-storage": "^9.0.0", + "@digitalbazaar/ed25519-signature-2020": "^5.4.0", + "@digitalbazaar/webkms-client": "^14.2.0", "@digitalbazaar/zcap": "^9.0.1", + "@digitalbazaar/zcap-context": "^2.0.0", "apisauce": "^3.1.0", "c8": "^10.1.3", "cross-env": "^7.0.3", diff --git a/test/test.config.js b/test/test.config.js index 0e7ff3d..959ed87 100644 --- a/test/test.config.js +++ b/test/test.config.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. */ import {config} from '@bedrock/core'; import {fileURLToPath} from 'node:url'; @@ -36,3 +36,21 @@ config['did-io'].methodOverrides.v1.disableFetch = true; config['profile-http'].additionalEdvs = { credentials: {referenceId: 'credentials'}, }; + +// `interactions` to be programmatically enabled in `test.js` +config['profile-http'].interactions.enabled = false; +config['profile-http'].interactions.types.test = { + localInteractionId: '1d35d09b-94c8-44d5-9d10-8dd3460a5fc4', + zcaps: { + // to be populated by `test.js` + readWriteExchanges: '{}' + } +}; +// test hmac key for push token feature; required for `interactions` +config.notify.push.hmacKey = { + id: 'urn:test:hmacKey', + secretKeyMultibase: 'uogHy02QDNPX4GID7dGUSGuYQ_Gv0WOIcpmTuKgt1ZNz7_4' +}; + +// service agent +config['service-agent'].kms.baseUrl = `${config.server.baseUri}/kms`; diff --git a/test/test.js b/test/test.js index 83fd4ff..4b0e0af 100644 --- a/test/test.js +++ b/test/test.js @@ -1,8 +1,12 @@ /*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. */ import * as bedrock from '@bedrock/core'; +import {CapabilityAgent} from '@digitalbazaar/webkms-client'; import {handlers} from '@bedrock/meter-http'; +import {meters as meterReporting} from '@bedrock/meter-usage-reporter'; +import {meters} from '@bedrock/meter'; +import {workflowService} from '@bedrock/vc-delivery'; import '@bedrock/ssm-mongodb'; import '@bedrock/kms'; import '@bedrock/edv-storage'; @@ -11,13 +15,29 @@ import '@bedrock/profile'; import '@bedrock/app-identity'; import '@bedrock/https-agent'; import '@bedrock/jsonld-document-loader'; -import '@bedrock/meter'; -import '@bedrock/meter-usage-reporter'; +import '@bedrock/notify'; import '@bedrock/passport'; import '@bedrock/server'; import '@bedrock/kms-http'; import '@bedrock/profile-http'; +// for generating workflow for interactions... +import {IdEncoder, IdGenerator} from 'bnid'; +import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020'; +import { + processInteractionConfig +} from '@bedrock/profile-http/lib/interactions.js'; +import {ZCAP_CLIENT} from '@bedrock/profile-http/lib/zcapClient.js'; +import {ZcapClient} from '@digitalbazaar/ezcap'; +// 128 bit random id generator +const idGenerator = new IdGenerator({bitLength: 128}); +// base58-multibase-multihash encoder +const idEncoder = new IdEncoder({ + encoding: 'base58', + multibase: true, + multihash: true +}); + import {mockData} from './mocha/mock.data.js'; bedrock.events.on('bedrock.init', async () => { @@ -37,5 +57,73 @@ bedrock.events.on('bedrock.init', async () => { handlers.setUseHandler({handler: ({meter} = {}) => ({meter})}); }); +bedrock.events.on('bedrock.ready', async () => { + // programmatically create workflow for interactions... + + // create some controller for the workflow + const secret = '53ad64ce-8e1d-11ec-bb12-10bf48838a41'; + const handle = 'test'; + const capabilityAgent = await CapabilityAgent.fromSecret({secret, handle}); + const {baseUri} = bedrock.config.server; + + const meter = { + id: idEncoder.encode(await idGenerator.generate()), + controller: capabilityAgent.id, + product: { + id: mockData.productIdMap.get('vc-workflow') + }, + serviceId: bedrock.config['app-identity'].seeds.services['vc-workflow'].id + }; + await meters.insert({meter}); + const meterId = `${baseUri}/meters/${meter.id}`; + await meterReporting.upsert({id: meterId, serviceType: 'vc-workflow'}); + + const localWorkflowId = idEncoder.encode(await idGenerator.generate()); + const config = { + id: `${baseUri}/workflows/${localWorkflowId}`, + sequence: 0, + controller: capabilityAgent.id, + meterId, + steps: { + finish: { + stepTemplate: { + type: 'jsonata', + template: ` + { + "inviteRequest": true, + "callback": { + "url": pushCallbackUrl + } + }` + } + } + }, + initialStep: 'finish' + }; + await workflowService.configStorage.insert({config}); + + // delegate ability to read/write exchanges for workflow to app identity + const signer = capabilityAgent.getSigner(); + const zcapClient = new ZcapClient({ + invocationSigner: signer, + delegationSigner: signer, + SuiteClass: Ed25519Signature2020 + }); + const readWriteExchanges = await zcapClient.delegate({ + capability: `urn:zcap:root:${encodeURIComponent(config.id)}`, + invocationTarget: `${config.id}/exchanges`, + controller: ZCAP_CLIENT.invocationSigner.id, + expires: new Date(Date.now() + 1000 * 60 * 60), + allowedActions: ['read', 'write'] + }); + // update interactions config + const interactionsConfig = bedrock.config['profile-http'].interactions; + interactionsConfig.enabled = true; + interactionsConfig.types.test.zcaps + .readWriteExchanges = JSON.stringify(readWriteExchanges); + // re-process interactions config + processInteractionConfig(); +}); + import '@bedrock/test'; bedrock.start();