From ed5012b33705b9fa278431f84ecf3b6cbb50b9e9 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Fri, 21 Jun 2024 12:46:05 -0400 Subject: [PATCH 01/17] Start implementing optional `interactions` feature. --- CHANGELOG.md | 9 ++++ lib/config.js | 19 ++++++++- lib/http.js | 74 ++++++++++++++++++++++++++++++++- schemas/bedrock-profile-http.js | 39 ++++++++++++++++- test/mocha/10-api.js | 2 +- test/package.json | 2 + 6 files changed, 141 insertions(+), 4 deletions(-) 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..f60afc4 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-2024 Digital Bazaar, Inc. All rights reserved. */ import * as bedrock from '@bedrock/core'; @@ -47,3 +47,20 @@ 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 interaction config +config.interactions = { + enabled: false, + // optional named workflows for interactions + /* Spec: + { + : { + ..., + zcaps: { + createExchange: + } + } + } + */ + workflows: {} +}; diff --git a/lib/http.js b/lib/http.js index 8ec4251..90671a3 100644 --- a/lib/http.js +++ b/lib/http.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2024 Digital Bazaar, Inc. All rights reserved. */ import * as bedrock from '@bedrock/core'; import * as schemas from '../schemas/bedrock-profile-http.js'; @@ -17,6 +17,7 @@ const {config, util: {BedrockError}} = bedrock; let APP_ID; let EDV_METER_CREATION_ZCAP; let WEBKMS_METER_CREATION_ZCAP; +let WORKFLOWS_MAP; let ZCAP_CLIENT; bedrock.events.on('bedrock.init', () => { @@ -31,6 +32,7 @@ bedrock.events.on('bedrock.init', () => { }); const cfg = bedrock.config['profile-http']; + const {edvMeterCreationZcap, webKmsMeterCreationZcap} = cfg; if(edvMeterCreationZcap) { EDV_METER_CREATION_ZCAP = JSON.parse(edvMeterCreationZcap); @@ -38,6 +40,29 @@ bedrock.events.on('bedrock.init', () => { if(webKmsMeterCreationZcap) { WEBKMS_METER_CREATION_ZCAP = JSON.parse(webKmsMeterCreationZcap); } + + // parse workflow configs when interactions are enabled + const {interactions} = cfg; + if(interactions?.enabled) { + const {workflows = {}} = interactions; + WORKFLOWS_MAP = new Map(); + for(const workflowName in workflows) { + const workflow = { + name: workflowName, + zcaps: new Map() + }; + const {zcaps} = workflows[workflowName]; + for(const zcapName in zcaps) { + const zcap = zcaps[zcapName]; + workflow.zcaps.set(zcapName, JSON.parse(zcap)); + } + if(!workflow.zcaps.has('createExchange')) { + throw new TypeError( + '"bedrock.config.profile-http.interactions.workflows" must each ' + + 'have "zcaps.createExchange".'); + } + } + } }); bedrock.events.on('bedrock-express.configure.routes', app => { @@ -47,6 +72,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { const profileAgentsPath = '/profile-agents'; const profileAgentPath = `${profileAgentsPath}/:profileAgentId`; const routes = { + interactions: `/interactions`, profiles: basePath, profileAgents: `${profileAgentsPath}`, profileAgent: `${profileAgentPath}`, @@ -402,6 +428,52 @@ bedrock.events.on('bedrock-express.configure.routes', app => { res.status(204).end(); })); + + // optional interactions feature + const {interactions} = cfg; + if(interactions?.enabled) { + // 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 {workflowName, exchange: {variables}} = req.body; + + const workflow = WORKFLOWS_MAP.get(workflowName); + if(!workflow) { + throw new BedrockError(`Workflow "${workflowName}" not found.`, { + name: 'NotFoundError', + details: { + httpStatusCode: 404, + public: true + } + }); + } + + // create exchange with given variables + const exchange = { + // 15 minute expiry in seconds + ttl: 60 * 15, + // template variables + variables: { + ...variables, + accountId + } + }; + const capability = workflow.zcaps.get('createExchange'); + const response = await ZCAP_CLIENT.write({json: exchange, capability}); + const exchangeId = response.headers.get('location'); + // reuse `localExchangeId` as interaction ID + const localExchangeId = exchangeId.slice(exchangeId.lastIndexOf('/')); + const id = `${config.server.baseUri}/${routes.interactions}` + + localExchangeId; + res.json({id, exchangeId}); + })); + + // FIXME: implement GET `/interactions/` + } }); // return select properties in the profileAgent record which does NOT include diff --git a/schemas/bedrock-profile-http.js b/schemas/bedrock-profile-http.js index e408684..39320a8 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-2024 Digital Bazaar, Inc. All rights reserved. */ const account = { title: 'Account', @@ -162,10 +162,47 @@ const delegateCapability = { } }; +const createInteraction = { + title: 'Create Interaction', + type: 'object', + required: ['workflowId', 'exchange'], + additionalProperties: false, + properties: { + workflowName: { + 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' + } + } + } + } + } +}; + export { profileAgent, profileAgents, accountQuery, delegateCapability, + createInteraction, zcaps }; diff --git a/test/mocha/10-api.js b/test/mocha/10-api.js index ca656de..72e27a8 100644 --- a/test/mocha/10-api.js +++ b/test/mocha/10-api.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2024 Digital Bazaar, Inc. All rights reserved. */ import * as helpers from './helpers.js'; import {config} from '@bedrock/core'; diff --git a/test/package.json b/test/package.json index 6aecb39..d5994bb 100644 --- a/test/package.json +++ b/test/package.json @@ -36,9 +36,11 @@ "@bedrock/ssm-mongodb": "^13.0.0", "@bedrock/test": "^8.2.0", "@bedrock/validation": "^7.1.1", + "@bedrock/vc-verifier": "^22.1.0", "@bedrock/veres-one-context": "^16.0.0", "@bedrock/zcap-storage": "^9.0.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", From 4dba2814bdc8897808b56bb3a616289c40dd8587 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Wed, 26 Jun 2024 23:41:48 -0400 Subject: [PATCH 02/17] Add route for getting interaction exchange state. --- lib/config.js | 4 ++- lib/http.js | 81 ++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/lib/config.js b/lib/config.js index f60afc4..6397131 100644 --- a/lib/config.js +++ b/lib/config.js @@ -56,8 +56,10 @@ config.interactions = { { : { ..., + // a unique local interaction ID for use in interaction URLs + localInteractionId, zcaps: { - createExchange: + readWriteExchanges: } } } diff --git a/lib/http.js b/lib/http.js index 90671a3..2435c00 100644 --- a/lib/http.js +++ b/lib/http.js @@ -17,7 +17,8 @@ const {config, util: {BedrockError}} = bedrock; let APP_ID; let EDV_METER_CREATION_ZCAP; let WEBKMS_METER_CREATION_ZCAP; -let WORKFLOWS_MAP; +let WORKFLOWS_BY_NAME_MAP; +let WORKFLOWS_BY_ID_MAP; let ZCAP_CLIENT; bedrock.events.on('bedrock.init', () => { @@ -45,22 +46,31 @@ bedrock.events.on('bedrock.init', () => { const {interactions} = cfg; if(interactions?.enabled) { const {workflows = {}} = interactions; - WORKFLOWS_MAP = new Map(); + WORKFLOWS_BY_NAME_MAP = new Map(); + WORKFLOWS_BY_ID_MAP = new Map(); for(const workflowName in workflows) { + const {localInteractionId, zcaps} = workflows[workflowName]; + if(!localInteractionId) { + throw new TypeError( + '"bedrock.config.profile-http.interactions.workflows" must each ' + + 'have "localInteractionId".'); + } const workflow = { + localInteractionId, name: workflowName, zcaps: new Map() }; - const {zcaps} = workflows[workflowName]; for(const zcapName in zcaps) { const zcap = zcaps[zcapName]; workflow.zcaps.set(zcapName, JSON.parse(zcap)); } - if(!workflow.zcaps.has('createExchange')) { + if(!workflow.zcaps.has('readWriteExchanges')) { throw new TypeError( '"bedrock.config.profile-http.interactions.workflows" must each ' + - 'have "zcaps.createExchange".'); + 'have "zcaps.readWriteExchanges".'); } + WORKFLOWS_BY_NAME_MAP.set(workflowName, workflow); + WORKFLOWS_BY_ID_MAP.set(localInteractionId, workflow); } } }); @@ -69,13 +79,15 @@ bedrock.events.on('bedrock-express.configure.routes', app => { const cfg = config['profile-http']; const {defaultProducts} = cfg; const {basePath} = cfg.routes; + const interactionsPath = '/interactions'; const profileAgentsPath = '/profile-agents'; const profileAgentPath = `${profileAgentsPath}/:profileAgentId`; const routes = { - interactions: `/interactions`, + interactions: interactionsPath, + interaction: `${interactionsPath}/:localInteractionId/:localExchangeId`, profiles: basePath, - profileAgents: `${profileAgentsPath}`, - profileAgent: `${profileAgentPath}`, + profileAgents: profileAgentsPath, + profileAgent: profileAgentPath, profileAgentClaim: `${profileAgentPath}/claim`, profileAgentCapabilities: `${profileAgentPath}/capabilities/delegate`, profileAgentCapabilitySet: `${profileAgentPath}/capability-set` @@ -441,7 +453,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { const {id: accountId} = req.user.account || {}; const {workflowName, exchange: {variables}} = req.body; - const workflow = WORKFLOWS_MAP.get(workflowName); + const workflow = WORKFLOWS_BY_NAME_MAP.get(workflowName); if(!workflow) { throw new BedrockError(`Workflow "${workflowName}" not found.`, { name: 'NotFoundError', @@ -462,17 +474,58 @@ bedrock.events.on('bedrock-express.configure.routes', app => { accountId } }; - const capability = workflow.zcaps.get('createExchange'); + const capability = workflow.zcaps.get('readWriteExchanges'); const response = await ZCAP_CLIENT.write({json: exchange, capability}); const exchangeId = response.headers.get('location'); - // reuse `localExchangeId` as interaction ID + const {localInteractionId} = workflow; + // reuse `localExchangeId` in path const localExchangeId = exchangeId.slice(exchangeId.lastIndexOf('/')); - const id = `${config.server.baseUri}/${routes.interactions}` + - localExchangeId; + const id = `${config.server.baseUri}/${routes.interactions}/` + + `${localInteractionId}/${localExchangeId}`; res.json({id, exchangeId}); })); - // FIXME: implement GET `/interactions/` + // gets an interaction by its "id" + app.get( + routes.interactionPath, + ensureAuthenticated, + asyncHandler(async (req, res) => { + const {id: accountId} = req.user.account || {}; + const {localInteractionId, localExchangeId} = req.params; + + const workflow = WORKFLOWS_BY_ID_MAP.get(localInteractionId); + if(!workflow) { + throw new BedrockError( + `Workflow "${localInteractionId}" not found.`, { + name: 'NotFoundError', + details: { + httpStatusCode: 404, + public: true + } + }); + } + + // FIXME: use in-memory cache to return exchange state if it was + // polled recently + + // fetch exchange + const capability = workflow.zcaps.get('readWriteExchanges'); + const response = await ZCAP_CLIENT.read({ + url: `${capability.invocationTarget}/${localExchangeId}`, + capability + }); + + // ensure `accountId` matches exchange variables + const {exchange: {state, variables}} = response; + if(variables.accountId !== accountId) { + throw new BedrockError( + 'The "account" is not authorized.', + 'NotAllowedError', + {httpStatusCode: 403, public: true}); + } + + res.json({exchange: {state, variables}}); + })); } }); From ed292b3a5eaca7f4dc37de5c50c5dcfd34f80631 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Wed, 26 Jun 2024 23:46:28 -0400 Subject: [PATCH 03/17] Move zcap client into its own file. --- lib/http.js | 14 ++------------ lib/zcapClient.js | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 lib/zcapClient.js diff --git a/lib/http.js b/lib/http.js index 2435c00..99da71d 100644 --- a/lib/http.js +++ b/lib/http.js @@ -5,12 +5,10 @@ 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'; const {config, util: {BedrockError}} = bedrock; @@ -19,19 +17,11 @@ let EDV_METER_CREATION_ZCAP; let WEBKMS_METER_CREATION_ZCAP; let WORKFLOWS_BY_NAME_MAP; let WORKFLOWS_BY_ID_MAP; -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; diff --git a/lib/zcapClient.js b/lib/zcapClient.js new file mode 100644 index 0000000..acc89a9 --- /dev/null +++ b/lib/zcapClient.js @@ -0,0 +1,21 @@ +/*! + * Copyright (c) 2020-2024 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 + }); +}); From 8b82d5b8970f3a23c2c7f7f7629173c14609aeb0 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Wed, 26 Jun 2024 23:50:03 -0400 Subject: [PATCH 04/17] Move interactions routes to their own file. --- lib/http.js | 127 +------------------------------------- lib/interactions.js | 146 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 124 deletions(-) create mode 100644 lib/interactions.js diff --git a/lib/http.js b/lib/http.js index 99da71d..0160d0e 100644 --- a/lib/http.js +++ b/lib/http.js @@ -10,13 +10,14 @@ import {getAppIdentity} from '@bedrock/app-identity'; import {createValidateMiddleware as validate} from '@bedrock/validation'; 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 WORKFLOWS_BY_NAME_MAP; -let WORKFLOWS_BY_ID_MAP; bedrock.events.on('bedrock.init', () => { const {id} = getAppIdentity(); @@ -31,50 +32,15 @@ bedrock.events.on('bedrock.init', () => { if(webKmsMeterCreationZcap) { WEBKMS_METER_CREATION_ZCAP = JSON.parse(webKmsMeterCreationZcap); } - - // parse workflow configs when interactions are enabled - const {interactions} = cfg; - if(interactions?.enabled) { - const {workflows = {}} = interactions; - WORKFLOWS_BY_NAME_MAP = new Map(); - WORKFLOWS_BY_ID_MAP = new Map(); - for(const workflowName in workflows) { - const {localInteractionId, zcaps} = workflows[workflowName]; - if(!localInteractionId) { - throw new TypeError( - '"bedrock.config.profile-http.interactions.workflows" must each ' + - 'have "localInteractionId".'); - } - const workflow = { - localInteractionId, - name: workflowName, - zcaps: new Map() - }; - for(const zcapName in zcaps) { - const zcap = zcaps[zcapName]; - workflow.zcaps.set(zcapName, JSON.parse(zcap)); - } - if(!workflow.zcaps.has('readWriteExchanges')) { - throw new TypeError( - '"bedrock.config.profile-http.interactions.workflows" must each ' + - 'have "zcaps.readWriteExchanges".'); - } - WORKFLOWS_BY_NAME_MAP.set(workflowName, workflow); - WORKFLOWS_BY_ID_MAP.set(localInteractionId, workflow); - } - } }); bedrock.events.on('bedrock-express.configure.routes', app => { const cfg = config['profile-http']; const {defaultProducts} = cfg; const {basePath} = cfg.routes; - const interactionsPath = '/interactions'; const profileAgentsPath = '/profile-agents'; const profileAgentPath = `${profileAgentsPath}/:profileAgentId`; const routes = { - interactions: interactionsPath, - interaction: `${interactionsPath}/:localInteractionId/:localExchangeId`, profiles: basePath, profileAgents: profileAgentsPath, profileAgent: profileAgentPath, @@ -430,93 +396,6 @@ bedrock.events.on('bedrock-express.configure.routes', app => { res.status(204).end(); })); - - // optional interactions feature - const {interactions} = cfg; - if(interactions?.enabled) { - // 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 {workflowName, exchange: {variables}} = req.body; - - const workflow = WORKFLOWS_BY_NAME_MAP.get(workflowName); - if(!workflow) { - throw new BedrockError(`Workflow "${workflowName}" not found.`, { - name: 'NotFoundError', - details: { - httpStatusCode: 404, - public: true - } - }); - } - - // create exchange with given variables - const exchange = { - // 15 minute expiry in seconds - ttl: 60 * 15, - // template variables - variables: { - ...variables, - accountId - } - }; - const capability = workflow.zcaps.get('readWriteExchanges'); - const response = await ZCAP_CLIENT.write({json: exchange, capability}); - const exchangeId = response.headers.get('location'); - const {localInteractionId} = workflow; - // reuse `localExchangeId` in path - const localExchangeId = exchangeId.slice(exchangeId.lastIndexOf('/')); - const id = `${config.server.baseUri}/${routes.interactions}/` + - `${localInteractionId}/${localExchangeId}`; - res.json({id, exchangeId}); - })); - - // gets an interaction by its "id" - app.get( - routes.interactionPath, - ensureAuthenticated, - asyncHandler(async (req, res) => { - const {id: accountId} = req.user.account || {}; - const {localInteractionId, localExchangeId} = req.params; - - const workflow = WORKFLOWS_BY_ID_MAP.get(localInteractionId); - if(!workflow) { - throw new BedrockError( - `Workflow "${localInteractionId}" not found.`, { - name: 'NotFoundError', - details: { - httpStatusCode: 404, - public: true - } - }); - } - - // FIXME: use in-memory cache to return exchange state if it was - // polled recently - - // fetch exchange - const capability = workflow.zcaps.get('readWriteExchanges'); - const response = await ZCAP_CLIENT.read({ - url: `${capability.invocationTarget}/${localExchangeId}`, - capability - }); - - // ensure `accountId` matches exchange variables - const {exchange: {state, variables}} = response; - if(variables.accountId !== accountId) { - throw new BedrockError( - 'The "account" is not authorized.', - 'NotAllowedError', - {httpStatusCode: 403, public: true}); - } - - res.json({exchange: {state, variables}}); - })); - } }); // return select properties in the profileAgent record which does NOT include diff --git a/lib/interactions.js b/lib/interactions.js new file mode 100644 index 0000000..5eab54a --- /dev/null +++ b/lib/interactions.js @@ -0,0 +1,146 @@ +/*! + * Copyright (c) 2020-2024 Digital Bazaar, Inc. All rights reserved. + */ +import * as bedrock from '@bedrock/core'; +import * as schemas from '../schemas/bedrock-profile-http.js'; +import {asyncHandler} from '@bedrock/express'; +import {ensureAuthenticated} from '@bedrock/passport'; +import {createValidateMiddleware as validate} from '@bedrock/validation'; +import {ZCAP_CLIENT} from './zcapClient.js'; + +const {config, util: {BedrockError}} = bedrock; + +let WORKFLOWS_BY_NAME_MAP; +let WORKFLOWS_BY_ID_MAP; + +bedrock.events.on('bedrock.init', () => { + const cfg = bedrock.config['profile-http']; + + // parse workflow configs when interactions are enabled + const {interactions} = cfg; + if(interactions?.enabled) { + const {workflows = {}} = interactions; + WORKFLOWS_BY_NAME_MAP = new Map(); + WORKFLOWS_BY_ID_MAP = new Map(); + for(const workflowName in workflows) { + const {localInteractionId, zcaps} = workflows[workflowName]; + if(!localInteractionId) { + throw new TypeError( + '"bedrock.config.profile-http.interactions.workflows" must each ' + + 'have "localInteractionId".'); + } + const workflow = { + localInteractionId, + name: workflowName, + zcaps: new Map() + }; + for(const zcapName in zcaps) { + const zcap = zcaps[zcapName]; + workflow.zcaps.set(zcapName, JSON.parse(zcap)); + } + if(!workflow.zcaps.has('readWriteExchanges')) { + throw new TypeError( + '"bedrock.config.profile-http.interactions.workflows" must each ' + + 'have "zcaps.readWriteExchanges".'); + } + WORKFLOWS_BY_NAME_MAP.set(workflowName, workflow); + WORKFLOWS_BY_ID_MAP.set(localInteractionId, workflow); + } + } +}); + +bedrock.events.on('bedrock-express.configure.routes', app => { + const cfg = config['profile-http']; + const interactionsPath = '/interactions'; + const routes = { + interactions: interactionsPath, + interaction: `${interactionsPath}/:localInteractionId/:localExchangeId` + }; + + // optional interactions feature + const {interactions} = cfg; + if(interactions?.enabled) { + // 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 {workflowName, exchange: {variables}} = req.body; + + const workflow = WORKFLOWS_BY_NAME_MAP.get(workflowName); + if(!workflow) { + throw new BedrockError(`Workflow "${workflowName}" not found.`, { + name: 'NotFoundError', + details: { + httpStatusCode: 404, + public: true + } + }); + } + + // create exchange with given variables + const exchange = { + // 15 minute expiry in seconds + ttl: 60 * 15, + // template variables + variables: { + ...variables, + accountId + } + }; + const capability = workflow.zcaps.get('readWriteExchanges'); + const response = await ZCAP_CLIENT.write({json: exchange, capability}); + const exchangeId = response.headers.get('location'); + const {localInteractionId} = workflow; + // reuse `localExchangeId` in path + const localExchangeId = exchangeId.slice(exchangeId.lastIndexOf('/')); + const id = `${config.server.baseUri}/${routes.interactions}/` + + `${localInteractionId}/${localExchangeId}`; + res.json({id, exchangeId}); + })); + + // gets an interaction by its "id" + app.get( + routes.interactionPath, + ensureAuthenticated, + asyncHandler(async (req, res) => { + const {id: accountId} = req.user.account || {}; + const {localInteractionId, localExchangeId} = req.params; + + const workflow = WORKFLOWS_BY_ID_MAP.get(localInteractionId); + if(!workflow) { + throw new BedrockError( + `Workflow "${localInteractionId}" not found.`, { + name: 'NotFoundError', + details: { + httpStatusCode: 404, + public: true + } + }); + } + + // FIXME: use in-memory cache to return exchange state if it was + // polled recently + + // fetch exchange + const capability = workflow.zcaps.get('readWriteExchanges'); + const response = await ZCAP_CLIENT.read({ + url: `${capability.invocationTarget}/${localExchangeId}`, + capability + }); + + // ensure `accountId` matches exchange variables + const {exchange: {state, variables}} = response; + if(variables.accountId !== accountId) { + throw new BedrockError( + 'The "account" is not authorized.', + 'NotAllowedError', + {httpStatusCode: 403, public: true}); + } + + res.json({exchange: {state, variables}}); + })); + } +}); From de6a522c4194161f6ea5586a4aa6bea7eaa6b809 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Wed, 26 Jun 2024 23:53:13 -0400 Subject: [PATCH 05/17] Return early when interactions not enabled. --- lib/interactions.js | 155 ++++++++++++++++++++++---------------------- 1 file changed, 79 insertions(+), 76 deletions(-) diff --git a/lib/interactions.js b/lib/interactions.js index 5eab54a..669cc87 100644 --- a/lib/interactions.js +++ b/lib/interactions.js @@ -51,96 +51,99 @@ bedrock.events.on('bedrock.init', () => { bedrock.events.on('bedrock-express.configure.routes', app => { const cfg = config['profile-http']; + + // interactions feature is optional, return early if not enabled + const {interactions} = cfg; + if(!interactions?.enabled) { + return; + } + const interactionsPath = '/interactions'; const routes = { interactions: interactionsPath, interaction: `${interactionsPath}/:localInteractionId/:localExchangeId` }; - // optional interactions feature - const {interactions} = cfg; - if(interactions?.enabled) { - // 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 {workflowName, exchange: {variables}} = req.body; - - const workflow = WORKFLOWS_BY_NAME_MAP.get(workflowName); - if(!workflow) { - throw new BedrockError(`Workflow "${workflowName}" not found.`, { + // 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 {workflowName, exchange: {variables}} = req.body; + + const workflow = WORKFLOWS_BY_NAME_MAP.get(workflowName); + if(!workflow) { + throw new BedrockError(`Workflow "${workflowName}" not found.`, { + name: 'NotFoundError', + details: { + httpStatusCode: 404, + public: true + } + }); + } + + // create exchange with given variables + const exchange = { + // 15 minute expiry in seconds + ttl: 60 * 15, + // template variables + variables: { + ...variables, + accountId + } + }; + const capability = workflow.zcaps.get('readWriteExchanges'); + const response = await ZCAP_CLIENT.write({json: exchange, capability}); + const exchangeId = response.headers.get('location'); + const {localInteractionId} = workflow; + // reuse `localExchangeId` in path + const localExchangeId = exchangeId.slice(exchangeId.lastIndexOf('/')); + const id = `${config.server.baseUri}/${routes.interactions}/` + + `${localInteractionId}/${localExchangeId}`; + res.json({id, exchangeId}); + })); + + // gets an interaction by its "id" + app.get( + routes.interactionPath, + ensureAuthenticated, + asyncHandler(async (req, res) => { + const {id: accountId} = req.user.account || {}; + const {localInteractionId, localExchangeId} = req.params; + + const workflow = WORKFLOWS_BY_ID_MAP.get(localInteractionId); + if(!workflow) { + throw new BedrockError( + `Workflow "${localInteractionId}" not found.`, { name: 'NotFoundError', details: { httpStatusCode: 404, public: true } }); - } - - // create exchange with given variables - const exchange = { - // 15 minute expiry in seconds - ttl: 60 * 15, - // template variables - variables: { - ...variables, - accountId - } - }; - const capability = workflow.zcaps.get('readWriteExchanges'); - const response = await ZCAP_CLIENT.write({json: exchange, capability}); - const exchangeId = response.headers.get('location'); - const {localInteractionId} = workflow; - // reuse `localExchangeId` in path - const localExchangeId = exchangeId.slice(exchangeId.lastIndexOf('/')); - const id = `${config.server.baseUri}/${routes.interactions}/` + - `${localInteractionId}/${localExchangeId}`; - res.json({id, exchangeId}); - })); - - // gets an interaction by its "id" - app.get( - routes.interactionPath, - ensureAuthenticated, - asyncHandler(async (req, res) => { - const {id: accountId} = req.user.account || {}; - const {localInteractionId, localExchangeId} = req.params; - - const workflow = WORKFLOWS_BY_ID_MAP.get(localInteractionId); - if(!workflow) { - throw new BedrockError( - `Workflow "${localInteractionId}" not found.`, { - name: 'NotFoundError', - details: { - httpStatusCode: 404, - public: true - } - }); - } + } - // FIXME: use in-memory cache to return exchange state if it was - // polled recently + // FIXME: use in-memory cache to return exchange state if it was + // polled recently - // fetch exchange - const capability = workflow.zcaps.get('readWriteExchanges'); - const response = await ZCAP_CLIENT.read({ - url: `${capability.invocationTarget}/${localExchangeId}`, - capability - }); + // fetch exchange + const capability = workflow.zcaps.get('readWriteExchanges'); + const response = await ZCAP_CLIENT.read({ + url: `${capability.invocationTarget}/${localExchangeId}`, + capability + }); - // ensure `accountId` matches exchange variables - const {exchange: {state, variables}} = response; - if(variables.accountId !== accountId) { - throw new BedrockError( - 'The "account" is not authorized.', - 'NotAllowedError', - {httpStatusCode: 403, public: true}); - } + // ensure `accountId` matches exchange variables + const {exchange: {state, variables}} = response; + if(variables.accountId !== accountId) { + throw new BedrockError( + 'The "account" is not authorized.', + 'NotAllowedError', + {httpStatusCode: 403, public: true}); + } - res.json({exchange: {state, variables}}); - })); - } + res.json({exchange: {state, variables}}); + })); }); From 815666afeb4c61b830103a2437c07e9ec076a317 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Thu, 27 Jun 2024 00:20:49 -0400 Subject: [PATCH 06/17] Limit per exchange polling via LRU cache. --- lib/config.js | 11 +++++- lib/interactions.js | 82 ++++++++++++++++++++++++++++----------------- package.json | 3 +- 3 files changed, 63 insertions(+), 33 deletions(-) diff --git a/lib/config.js b/lib/config.js index 6397131..7fa37c9 100644 --- a/lib/config.js +++ b/lib/config.js @@ -51,7 +51,16 @@ config.ensureConfigOverride.fields.push(meterServiceName); // optional interaction config config.interactions = { enabled: false, - // optional named workflows for interactions + caches: { + exchangePolling: { + // each cache value is only a boolean (the key is ~64 bytes); one entry + // per exchange being actively polled, 1M = ~60 MiB + max: 1000000, + // polling allowed no more than once per second by default + ttl: 1000 + } + }, + // named workflows for interactions /* Spec: { : { diff --git a/lib/interactions.js b/lib/interactions.js index 669cc87..abd7a87 100644 --- a/lib/interactions.js +++ b/lib/interactions.js @@ -5,6 +5,7 @@ import * as bedrock from '@bedrock/core'; import * as schemas from '../schemas/bedrock-profile-http.js'; import {asyncHandler} from '@bedrock/express'; import {ensureAuthenticated} from '@bedrock/passport'; +import {LRUCache} from 'lru-cache'; import {createValidateMiddleware as validate} from '@bedrock/validation'; import {ZCAP_CLIENT} from './zcapClient.js'; @@ -12,41 +13,49 @@ const {config, util: {BedrockError}} = bedrock; let WORKFLOWS_BY_NAME_MAP; let WORKFLOWS_BY_ID_MAP; +let EXCHANGE_POLLING_CACHE; bedrock.events.on('bedrock.init', () => { - const cfg = bedrock.config['profile-http']; + const cfg = config['profile-http']; - // parse workflow configs when interactions are enabled + // interactions feature is optional, return early if not enabled const {interactions} = cfg; - if(interactions?.enabled) { - const {workflows = {}} = interactions; - WORKFLOWS_BY_NAME_MAP = new Map(); - WORKFLOWS_BY_ID_MAP = new Map(); - for(const workflowName in workflows) { - const {localInteractionId, zcaps} = workflows[workflowName]; - if(!localInteractionId) { - throw new TypeError( - '"bedrock.config.profile-http.interactions.workflows" must each ' + - 'have "localInteractionId".'); - } - const workflow = { - localInteractionId, - name: workflowName, - zcaps: new Map() - }; - for(const zcapName in zcaps) { - const zcap = zcaps[zcapName]; - workflow.zcaps.set(zcapName, JSON.parse(zcap)); - } - if(!workflow.zcaps.has('readWriteExchanges')) { - throw new TypeError( - '"bedrock.config.profile-http.interactions.workflows" must each ' + - 'have "zcaps.readWriteExchanges".'); - } - WORKFLOWS_BY_NAME_MAP.set(workflowName, workflow); - WORKFLOWS_BY_ID_MAP.set(localInteractionId, workflow); + if(!interactions?.enabled) { + return; + } + + // parse workflow configs when interactions are enabled + const {workflows = {}} = interactions; + WORKFLOWS_BY_NAME_MAP = new Map(); + WORKFLOWS_BY_ID_MAP = new Map(); + for(const workflowName in workflows) { + const {localInteractionId, zcaps} = workflows[workflowName]; + if(!localInteractionId) { + throw new TypeError( + '"bedrock.config.profile-http.interactions.workflows" must each ' + + 'have "localInteractionId".'); + } + const workflow = { + localInteractionId, + name: workflowName, + zcaps: new Map() + }; + for(const zcapName in zcaps) { + const zcap = zcaps[zcapName]; + workflow.zcaps.set(zcapName, JSON.parse(zcap)); + } + if(!workflow.zcaps.has('readWriteExchanges')) { + throw new TypeError( + '"bedrock.config.profile-http.interactions.workflows" must each ' + + 'have "zcaps.readWriteExchanges".'); } + WORKFLOWS_BY_NAME_MAP.set(workflowName, workflow); + WORKFLOWS_BY_ID_MAP.set(localInteractionId, workflow); } + + // setup caches + const {caches} = interactions; + EXCHANGE_POLLING_CACHE = new LRUCache(caches.exchangePolling); }); bedrock.events.on('bedrock-express.configure.routes', app => { @@ -64,6 +73,8 @@ bedrock.events.on('bedrock-express.configure.routes', app => { interaction: `${interactionsPath}/:localInteractionId/:localExchangeId` }; + const retryAfter = Math.ceil(EXCHANGE_POLLING_CACHE.ttl / 1000); + // create an interaction to exchange VCs app.post( routes.interactions, @@ -125,8 +136,14 @@ bedrock.events.on('bedrock-express.configure.routes', app => { }); } - // FIXME: use in-memory cache to return exchange state if it was - // polled recently + // check if entry is in exchange polling cache, if so, return + // "too many requests" error response + const key = `${localExchangeId}/${localExchangeId}`; + if(EXCHANGE_POLLING_CACHE.get(key)) { + res.set('retry-after', retryAfter); + res.status(429).json({retryAfter}); + return; + } // fetch exchange const capability = workflow.zcaps.get('readWriteExchanges'); @@ -135,6 +152,9 @@ bedrock.events.on('bedrock-express.configure.routes', app => { capability }); + // prevent more polling on the same exchange for another second + EXCHANGE_POLLING_CACHE.set(key, true); + // ensure `accountId` matches exchange variables const {exchange: {state, variables}} = response; if(variables.accountId !== accountId) { diff --git a/package.json b/package.json index 9462a52..f8dd016 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "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", + "lru-cache": "^10.2.2" }, "peerDependencies": { "@bedrock/app-identity": "^4.0.0", From cdf27f7887c6a78d27acde22f4d0954daa3d1e01 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Mon, 1 Jul 2024 00:46:39 -0400 Subject: [PATCH 07/17] Start adding interaction tests. --- lib/config.js | 2 +- lib/interactions.js | 4 +- schemas/bedrock-profile-http.js | 2 +- test/mocha/10-api.js | 80 +++++++++++++++++++++++++++++++++ test/test.config.js | 12 ++++- 5 files changed, 95 insertions(+), 5 deletions(-) diff --git a/lib/config.js b/lib/config.js index 7fa37c9..cb0fb12 100644 --- a/lib/config.js +++ b/lib/config.js @@ -49,7 +49,7 @@ cc(`${meterServiceName}.url`, () => `${bedrock.config.server.baseUri}/meters`); config.ensureConfigOverride.fields.push(meterServiceName); // optional interaction config -config.interactions = { +cfg.interactions = { enabled: false, caches: { exchangePolling: { diff --git a/lib/interactions.js b/lib/interactions.js index abd7a87..a3138f3 100644 --- a/lib/interactions.js +++ b/lib/interactions.js @@ -113,12 +113,12 @@ bedrock.events.on('bedrock-express.configure.routes', app => { const localExchangeId = exchangeId.slice(exchangeId.lastIndexOf('/')); const id = `${config.server.baseUri}/${routes.interactions}/` + `${localInteractionId}/${localExchangeId}`; - res.json({id, exchangeId}); + res.json({interactionId: id, exchangeId}); })); // gets an interaction by its "id" app.get( - routes.interactionPath, + routes.interaction, ensureAuthenticated, asyncHandler(async (req, res) => { const {id: accountId} = req.user.account || {}; diff --git a/schemas/bedrock-profile-http.js b/schemas/bedrock-profile-http.js index 39320a8..5d1f3a2 100644 --- a/schemas/bedrock-profile-http.js +++ b/schemas/bedrock-profile-http.js @@ -165,7 +165,7 @@ const delegateCapability = { const createInteraction = { title: 'Create Interaction', type: 'object', - required: ['workflowId', 'exchange'], + required: ['workflowName', 'exchange'], additionalProperties: false, properties: { workflowName: { diff --git a/test/mocha/10-api.js b/test/mocha/10-api.js index 72e27a8..3caca8c 100644 --- a/test/mocha/10-api.js +++ b/test/mocha/10-api.js @@ -847,6 +847,86 @@ describe('bedrock-profile-http', () => { result.data.type.should.equal('NotAllowedError'); }); }); + describe('interactions', () => { + // FIXME: create workflow instance + // before() + 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 occured in the 'Create Interaction' validator.`); + }); + it('fails to create a new interaction with unknown workflow', async () => { + let result; + let error; + try { + result = await api.post('/interactions', { + workflowName: '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('Workflow "does-not-exist" not found.'); + }); + it.skip('creates a new interaction', async () => { + let interactionId; + { + let result; + let error; + try { + result = await api.post('/interactions', { + workflowName: '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; + } + + // 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); + console.log('result.data', result.data); + // FIXME: assert on result.data + } + }); + }); }); // end bedrock-profile-http async function _createNProfiles({n, api, account, didMethod}) { diff --git a/test/test.config.js b/test/test.config.js index 0e7ff3d..edc7e15 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-2024 Digital Bazaar, Inc. All rights reserved. */ import {config} from '@bedrock/core'; import {fileURLToPath} from 'node:url'; @@ -36,3 +36,13 @@ config['did-io'].methodOverrides.v1.disableFetch = true; config['profile-http'].additionalEdvs = { credentials: {referenceId: 'credentials'}, }; + +// enable optional `interactions` +config['profile-http'].interactions.enabled = true; +config['profile-http'].interactions.workflows.test = { + localInteractionId: '1d35d09b-94c8-44d5-9d10-8dd3460a5fc4', + zcaps: { + // FIXME: + readWriteExchanges: '{}' + } +}; From 8fc0a1c656ca378e01b256bb688df5733ab53c72 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 13 Apr 2025 13:29:24 -0400 Subject: [PATCH 08/17] Fix typo in test assertion. --- test/mocha/10-api.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/mocha/10-api.js b/test/mocha/10-api.js index 3caca8c..1d937ef 100644 --- a/test/mocha/10-api.js +++ b/test/mocha/10-api.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2024 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'; @@ -863,7 +863,7 @@ describe('bedrock-profile-http', () => { result.status.should.equal(400); result.ok.should.equal(false); result.data.message.should.equal( - `A validation error occured in the 'Create Interaction' validator.`); + `A validation error occurred in the 'Create Interaction' validator.`); }); it('fails to create a new interaction with unknown workflow', async () => { let result; From aff0596300fb388b2c3a0623dc9490597c62bf9f Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 13 Jul 2025 12:27:02 -0400 Subject: [PATCH 09/17] Add FIXME. --- lib/config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/config.js b/lib/config.js index cb0fb12..423f767 100644 --- a/lib/config.js +++ b/lib/config.js @@ -52,6 +52,7 @@ config.ensureConfigOverride.fields.push(meterServiceName); cfg.interactions = { enabled: false, caches: { + // FIXME: remove; replace with bedrock-notify exchangePolling: { // each cache value is only a boolean (the key is ~64 bytes); one entry // per exchange being actively polled, 1M = ~60 MiB From e4b11efb19910c7057d788ef900b8f90bc448b38 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Mon, 14 Jul 2025 16:57:10 -0400 Subject: [PATCH 10/17] Integrate interactions with `@bedrock/notify`. --- lib/config.js | 11 +---- lib/interactions.js | 106 ++++++++++++++++++++++++++++---------------- package.json | 3 +- test/package.json | 1 + test/test.js | 3 +- 5 files changed, 75 insertions(+), 49 deletions(-) diff --git a/lib/config.js b/lib/config.js index 423f767..0182a23 100644 --- a/lib/config.js +++ b/lib/config.js @@ -50,17 +50,8 @@ config.ensureConfigOverride.fields.push(meterServiceName); // optional interaction config cfg.interactions = { + // FIXME: add qr-code route fallback for non-accept-json "protocols" requests enabled: false, - caches: { - // FIXME: remove; replace with bedrock-notify - exchangePolling: { - // each cache value is only a boolean (the key is ~64 bytes); one entry - // per exchange being actively polled, 1M = ~60 MiB - max: 1000000, - // polling allowed no more than once per second by default - ttl: 1000 - } - }, // named workflows for interactions /* Spec: { diff --git a/lib/interactions.js b/lib/interactions.js index a3138f3..25596e9 100644 --- a/lib/interactions.js +++ b/lib/interactions.js @@ -1,19 +1,20 @@ /*! - * Copyright (c) 2020-2024 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 {poll, pollers} from '@bedrock/notify'; +import {agent} from '@bedrock/https-agent'; import {asyncHandler} from '@bedrock/express'; import {ensureAuthenticated} from '@bedrock/passport'; -import {LRUCache} from 'lru-cache'; +import {httpClient} from '@digitalbazaar/http-client'; import {createValidateMiddleware as validate} from '@bedrock/validation'; -import {ZCAP_CLIENT} from './zcapClient.js'; +import {ZCAP_CLIENT as zcapClient} from './zcapClient.js'; const {config, util: {BedrockError}} = bedrock; let WORKFLOWS_BY_NAME_MAP; let WORKFLOWS_BY_ID_MAP; -let EXCHANGE_POLLING_CACHE; bedrock.events.on('bedrock.init', () => { const cfg = config['profile-http']; @@ -52,10 +53,6 @@ bedrock.events.on('bedrock.init', () => { WORKFLOWS_BY_NAME_MAP.set(workflowName, workflow); WORKFLOWS_BY_ID_MAP.set(localInteractionId, workflow); } - - // setup caches - const {caches} = interactions; - EXCHANGE_POLLING_CACHE = new LRUCache(caches.exchangePolling); }); bedrock.events.on('bedrock-express.configure.routes', app => { @@ -73,8 +70,6 @@ bedrock.events.on('bedrock-express.configure.routes', app => { interaction: `${interactionsPath}/:localInteractionId/:localExchangeId` }; - const retryAfter = Math.ceil(EXCHANGE_POLLING_CACHE.ttl / 1000); - // create an interaction to exchange VCs app.post( routes.interactions, @@ -97,6 +92,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { // 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 @@ -106,7 +102,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { } }; const capability = workflow.zcaps.get('readWriteExchanges'); - const response = await ZCAP_CLIENT.write({json: exchange, capability}); + const response = await zcapClient.write({json: exchange, capability}); const exchangeId = response.headers.get('location'); const {localInteractionId} = workflow; // reuse `localExchangeId` in path @@ -120,9 +116,13 @@ bedrock.events.on('bedrock-express.configure.routes', app => { app.get( routes.interaction, ensureAuthenticated, + // FIXME: add URL query validator that requires no query or `iuv=1` only asyncHandler(async (req, res) => { const {id: accountId} = req.user.account || {}; - const {localInteractionId, localExchangeId} = req.params; + const { + params: {localInteractionId, localExchangeId}, + query: {iuv} + } = req; const workflow = WORKFLOWS_BY_ID_MAP.get(localInteractionId); if(!workflow) { @@ -136,34 +136,66 @@ bedrock.events.on('bedrock-express.configure.routes', app => { }); } - // check if entry is in exchange polling cache, if so, return - // "too many requests" error response - const key = `${localExchangeId}/${localExchangeId}`; - if(EXCHANGE_POLLING_CACHE.get(key)) { - res.set('retry-after', retryAfter); - res.status(429).json({retryAfter}); - return; + // determine full exchange ID based on related capability + const capability = workflow.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 + }); + } } - // fetch exchange - const capability = workflow.zcaps.get('readWriteExchanges'); - const response = await ZCAP_CLIENT.read({ - url: `${capability.invocationTarget}/${localExchangeId}`, - capability + // poll the exchange... + const result = await poll({ + id: exchangeId, + poller: pollers.createExchangePoller({ + zcapClient, + capability, + filterExchange({exchange/*, previousPollResult*/}) { + // ensure `accountId` matches exchange variables + if(exchange?.variables.accountId !== accountId) { + throw new BedrockError( + 'Not authorized.', + 'NotAllowedError', + {httpStatusCode: 403, public: true}); + } + // return only information that should be accessible to client + return { + exchange + // FIXME: filter info once final step name and info is determined + /* + exchange: { + state: exchange.state, + result: exchange.variables.results?.finish + }*/ + }; + } + }), + // set a TTL of 1 seconds 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 backend more + // frequently than 1 second + ttl: 1000 }); - // prevent more polling on the same exchange for another second - EXCHANGE_POLLING_CACHE.set(key, true); - - // ensure `accountId` matches exchange variables - const {exchange: {state, variables}} = response; - if(variables.accountId !== accountId) { - throw new BedrockError( - 'The "account" is not authorized.', - 'NotAllowedError', - {httpStatusCode: 403, public: true}); - } - - res.json({exchange: {state, variables}}); + res.json(result); })); }); diff --git a/package.json b/package.json index f8dd016..4eee0b9 100644 --- a/package.json +++ b/package.json @@ -31,13 +31,14 @@ "dependencies": { "@digitalbazaar/ed25519-signature-2020": "^5.4.0", "@digitalbazaar/ezcap": "^4.1.0", - "lru-cache": "^10.2.2" + "@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/test/package.json b/test/package.json index d5994bb..d479b74 100644 --- a/test/package.json +++ b/test/package.json @@ -27,6 +27,7 @@ "@bedrock/meter-http": "^14.0.0", "@bedrock/meter-usage-reporter": "^10.0.0", "@bedrock/mongodb": "^11.0.0", + "@bedrock/notify": "^1.1.0", "@bedrock/package-manager": "^3.0.0", "@bedrock/passport": "^12.0.0", "@bedrock/profile": "^26.0.0", diff --git a/test/test.js b/test/test.js index 83fd4ff..e675ee0 100644 --- a/test/test.js +++ b/test/test.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'; import {handlers} from '@bedrock/meter-http'; @@ -13,6 +13,7 @@ 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'; From 067e6375641f8e16651c65de4d4a9504ad714164 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Mon, 14 Jul 2025 17:53:45 -0400 Subject: [PATCH 11/17] Refactor interactions to use "interaction types" terminology. --- lib/config.js | 8 ++-- lib/interactions.js | 76 ++++++++++++++++----------------- schemas/bedrock-profile-http.js | 6 +-- test/mocha/10-api.js | 11 ++--- test/test.config.js | 2 +- 5 files changed, 50 insertions(+), 53 deletions(-) diff --git a/lib/config.js b/lib/config.js index 0182a23..af81d6f 100644 --- a/lib/config.js +++ b/lib/config.js @@ -48,14 +48,14 @@ cc(`${meterServiceName}.url`, () => `${bedrock.config.server.baseUri}/meters`); // ensure meter service config is overridden in deployments config.ensureConfigOverride.fields.push(meterServiceName); -// optional interaction config +// optional interactions config cfg.interactions = { // FIXME: add qr-code route fallback for non-accept-json "protocols" requests enabled: false, - // named workflows for interactions + // types of interactions, type name => definition /* Spec: { - : { + : { ..., // a unique local interaction ID for use in interaction URLs localInteractionId, @@ -65,5 +65,5 @@ cfg.interactions = { } } */ - workflows: {} + types: {} }; diff --git a/lib/interactions.js b/lib/interactions.js index 25596e9..0fd1c77 100644 --- a/lib/interactions.js +++ b/lib/interactions.js @@ -13,54 +13,52 @@ import {ZCAP_CLIENT as zcapClient} from './zcapClient.js'; const {config, util: {BedrockError}} = bedrock; -let WORKFLOWS_BY_NAME_MAP; -let WORKFLOWS_BY_ID_MAP; +let DEFINITIONS_BY_TYPE_MAP; +let DEFINITIONS_BY_ID_MAP; bedrock.events.on('bedrock.init', () => { const cfg = config['profile-http']; // interactions feature is optional, return early if not enabled - const {interactions} = cfg; - if(!interactions?.enabled) { + if(!cfg.interactions?.enabled) { return; } - // parse workflow configs when interactions are enabled - const {workflows = {}} = interactions; - WORKFLOWS_BY_NAME_MAP = new Map(); - WORKFLOWS_BY_ID_MAP = new Map(); - for(const workflowName in workflows) { - const {localInteractionId, zcaps} = workflows[workflowName]; + // 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.interactions.workflows" must each ' + + '"bedrock.config.profile-http.interaction.types" must each ' + 'have "localInteractionId".'); } - const workflow = { + const definition = { + name: typeName, localInteractionId, - name: workflowName, zcaps: new Map() }; for(const zcapName in zcaps) { const zcap = zcaps[zcapName]; - workflow.zcaps.set(zcapName, JSON.parse(zcap)); + definition.zcaps.set(zcapName, JSON.parse(zcap)); } - if(!workflow.zcaps.has('readWriteExchanges')) { + if(!definition.zcaps.has('readWriteExchanges')) { throw new TypeError( - '"bedrock.config.profile-http.interactions.workflows" must each ' + + '"bedrock.config.profile-http.interaction.types" must each ' + 'have "zcaps.readWriteExchanges".'); } - WORKFLOWS_BY_NAME_MAP.set(workflowName, workflow); - WORKFLOWS_BY_ID_MAP.set(localInteractionId, workflow); + DEFINITIONS_BY_TYPE_MAP.set(typeName, definition); + DEFINITIONS_BY_ID_MAP.set(localInteractionId, definition); } }); bedrock.events.on('bedrock-express.configure.routes', app => { const cfg = config['profile-http']; - // interactions feature is optional, return early if not enabled - const {interactions} = cfg; - if(!interactions?.enabled) { + // interaction feature is optional, return early if not enabled + if(!cfg.interactions?.enabled) { return; } @@ -77,11 +75,11 @@ bedrock.events.on('bedrock-express.configure.routes', app => { validate({bodySchema: schemas.createInteraction}), asyncHandler(async (req, res) => { const {id: accountId} = req.user.account || {}; - const {workflowName, exchange: {variables}} = req.body; + const {type, exchange: {variables}} = req.body; - const workflow = WORKFLOWS_BY_NAME_MAP.get(workflowName); - if(!workflow) { - throw new BedrockError(`Workflow "${workflowName}" not found.`, { + const definition = DEFINITIONS_BY_TYPE_MAP.get(type); + if(!definition) { + throw new BedrockError(`Interaction type "${type}" not found.`, { name: 'NotFoundError', details: { httpStatusCode: 404, @@ -101,10 +99,10 @@ bedrock.events.on('bedrock-express.configure.routes', app => { accountId } }; - const capability = workflow.zcaps.get('readWriteExchanges'); + const capability = definition.zcaps.get('readWriteExchanges'); const response = await zcapClient.write({json: exchange, capability}); const exchangeId = response.headers.get('location'); - const {localInteractionId} = workflow; + const {localInteractionId} = definition; // reuse `localExchangeId` in path const localExchangeId = exchangeId.slice(exchangeId.lastIndexOf('/')); const id = `${config.server.baseUri}/${routes.interactions}/` + @@ -112,7 +110,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { res.json({interactionId: id, exchangeId}); })); - // gets an interaction by its "id" + // gets an interaction app.get( routes.interaction, ensureAuthenticated, @@ -124,20 +122,18 @@ bedrock.events.on('bedrock-express.configure.routes', app => { query: {iuv} } = req; - const workflow = WORKFLOWS_BY_ID_MAP.get(localInteractionId); - if(!workflow) { + // get interaction definition + const definition = DEFINITIONS_BY_ID_MAP.get(localInteractionId); + if(!definition) { throw new BedrockError( - `Workflow "${localInteractionId}" not found.`, { + `Interaction type for "${localInteractionId}" not found.`, { name: 'NotFoundError', - details: { - httpStatusCode: 404, - public: true - } + details: {httpStatusCode: 404, public: true} }); } // determine full exchange ID based on related capability - const capability = workflow.zcaps.get('readWriteExchanges'); + const capability = definition.zcaps.get('readWriteExchanges'); const exchangeId = `${capability.invocationTarget}/${localExchangeId}`; // if an "Interaction URL Version" is present send "protocols" @@ -172,10 +168,10 @@ bedrock.events.on('bedrock-express.configure.routes', app => { filterExchange({exchange/*, previousPollResult*/}) { // ensure `accountId` matches exchange variables if(exchange?.variables.accountId !== accountId) { - throw new BedrockError( - 'Not authorized.', - 'NotAllowedError', - {httpStatusCode: 403, public: true}); + throw new BedrockError('Not authorized.', { + name: 'NotAllowedError', + details: {httpStatusCode: 403, public: true} + }); } // return only information that should be accessible to client return { diff --git a/schemas/bedrock-profile-http.js b/schemas/bedrock-profile-http.js index 5d1f3a2..bed9b4e 100644 --- a/schemas/bedrock-profile-http.js +++ b/schemas/bedrock-profile-http.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2024 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. */ const account = { title: 'Account', @@ -165,10 +165,10 @@ const delegateCapability = { const createInteraction = { title: 'Create Interaction', type: 'object', - required: ['workflowName', 'exchange'], + required: ['type', 'exchange'], additionalProperties: false, properties: { - workflowName: { + type: { type: 'string' }, exchange: { diff --git a/test/mocha/10-api.js b/test/mocha/10-api.js index 1d937ef..6853830 100644 --- a/test/mocha/10-api.js +++ b/test/mocha/10-api.js @@ -848,7 +848,7 @@ describe('bedrock-profile-http', () => { }); }); describe('interactions', () => { - // FIXME: create workflow instance + // FIXME: create associated workflow instance // before() it('fails to create a new interaction with bad post data', async () => { let result; @@ -865,12 +865,12 @@ describe('bedrock-profile-http', () => { result.data.message.should.equal( `A validation error occurred in the 'Create Interaction' validator.`); }); - it('fails to create a new interaction with unknown workflow', async () => { + it('fails to create a new interaction with unknown type', async () => { let result; let error; try { result = await api.post('/interactions', { - workflowName: 'does-not-exist', + type: 'does-not-exist', exchange: { variables: {} } @@ -883,7 +883,8 @@ describe('bedrock-profile-http', () => { result.status.should.equal(404); result.ok.should.equal(false); result.data.name.should.equal('NotFoundError'); - result.data.message.should.equal('Workflow "does-not-exist" not found.'); + result.data.message.should.equal( + 'Interaction type "does-not-exist" not found.'); }); it.skip('creates a new interaction', async () => { let interactionId; @@ -892,7 +893,7 @@ describe('bedrock-profile-http', () => { let error; try { result = await api.post('/interactions', { - workflowName: 'test', + type: 'test', exchange: { variables: {} } diff --git a/test/test.config.js b/test/test.config.js index edc7e15..4a29cb1 100644 --- a/test/test.config.js +++ b/test/test.config.js @@ -39,7 +39,7 @@ config['profile-http'].additionalEdvs = { // enable optional `interactions` config['profile-http'].interactions.enabled = true; -config['profile-http'].interactions.workflows.test = { +config['profile-http'].interactions.types.test = { localInteractionId: '1d35d09b-94c8-44d5-9d10-8dd3460a5fc4', zcaps: { // FIXME: From b50cbac2e367d82b7b4a798683ad8807fffe7705 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Tue, 15 Jul 2025 12:18:54 -0400 Subject: [PATCH 12/17] Add push notification callbacks to interaction processing. --- lib/interactions.js | 135 +++++++++++++++++++++++++++++++------------- test/test.config.js | 5 ++ 2 files changed, 101 insertions(+), 39 deletions(-) diff --git a/lib/interactions.js b/lib/interactions.js index 0fd1c77..d2956e6 100644 --- a/lib/interactions.js +++ b/lib/interactions.js @@ -3,7 +3,7 @@ */ import * as bedrock from '@bedrock/core'; import * as schemas from '../schemas/bedrock-profile-http.js'; -import {poll, pollers} from '@bedrock/notify'; +import {poll, pollers, push} from '@bedrock/notify'; import {agent} from '@bedrock/https-agent'; import {asyncHandler} from '@bedrock/express'; import {ensureAuthenticated} from '@bedrock/passport'; @@ -16,6 +16,12 @@ 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', () => { const cfg = config['profile-http']; @@ -65,9 +71,13 @@ bedrock.events.on('bedrock-express.configure.routes', app => { const interactionsPath = '/interactions'; const routes = { interactions: interactionsPath, - interaction: `${interactionsPath}/:localInteractionId/:localExchangeId` + 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, @@ -88,6 +98,15 @@ bedrock.events.on('bedrock-express.configure.routes', app => { }); } + // create a push token + const {token} = await push.createPushToken({event: 'exchangeUpdated'}); + + // compute callback URL + const {localInteractionId} = definition; + const callbackUrl = + `${baseUri}${interactionsPath}/${localInteractionId}` + + `/callbacks/${token}`; + // create exchange with given variables const exchange = { // FIXME: use `expires` instead of now-deprecated `ttl` @@ -96,13 +115,15 @@ bedrock.events.on('bedrock-express.configure.routes', app => { // template variables variables: { ...variables, + callback: { + url: callbackUrl + }, accountId } }; const capability = definition.zcaps.get('readWriteExchanges'); const response = await zcapClient.write({json: exchange, capability}); const exchangeId = response.headers.get('location'); - const {localInteractionId} = definition; // reuse `localExchangeId` in path const localExchangeId = exchangeId.slice(exchangeId.lastIndexOf('/')); const id = `${config.server.baseUri}/${routes.interactions}/` + @@ -123,14 +144,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { } = req; // get interaction definition - 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} - }); - } + const definition = _getInteractionDefinition({localInteractionId}); // determine full exchange ID based on related capability const capability = definition.zcaps.get('readWriteExchanges'); @@ -162,36 +176,79 @@ bedrock.events.on('bedrock-express.configure.routes', app => { // poll the exchange... const result = await poll({ id: exchangeId, - poller: pollers.createExchangePoller({ - zcapClient, - capability, - filterExchange({exchange/*, previousPollResult*/}) { - // ensure `accountId` matches exchange variables - if(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 - // FIXME: filter info once final step name and info is determined - /* - exchange: { - state: exchange.state, - result: exchange.variables.results?.finish - }*/ - }; - } - }), - // set a TTL of 1 seconds 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 backend more - // frequently than 1 second - ttl: 1000 + poller: _createExchangePoller({accountId, capability}), + ttl: POLL_TTL }); res.json(result); })); + + // 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); + })); }); + +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 + // FIXME: filter info once final step name and info is determined + /* + 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/test/test.config.js b/test/test.config.js index 4a29cb1..9196ba7 100644 --- a/test/test.config.js +++ b/test/test.config.js @@ -46,3 +46,8 @@ config['profile-http'].interactions.types.test = { readWriteExchanges: '{}' } }; +// test hmac key for push token feature; required for `interactions` +config.notify.push.hmacKey = { + id: 'urn:test:hmacKey', + secretKeyMultibase: 'uogHy02QDNPX4GID7dGUSGuYQ_Gv0WOIcpmTuKgt1ZNz7_4' +}; From ae7cb67e52d895824c16e3efb517a83aef1a737c Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Thu, 11 Sep 2025 15:41:25 -0400 Subject: [PATCH 13/17] Refactor tests. --- test/mocha/10-profiles.js | 161 ++++++++++++++ test/mocha/{10-api.js => 20-profileAgents.js} | 197 +----------------- test/mocha/30-interactions.js | 110 ++++++++++ 3 files changed, 273 insertions(+), 195 deletions(-) create mode 100644 test/mocha/10-profiles.js rename test/mocha/{10-api.js => 20-profileAgents.js} (81%) create mode 100644 test/mocha/30-interactions.js 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 81% rename from test/mocha/10-api.js rename to test/mocha/20-profileAgents.js index 6853830..a0d4319 100644 --- a/test/mocha/10-api.js +++ b/test/mocha/20-profileAgents.js @@ -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,88 +735,7 @@ describe('bedrock-profile-http', () => { result.data.type.should.equal('NotAllowedError'); }); }); - describe('interactions', () => { - // FIXME: create associated workflow instance - // before() - 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.skip('creates a new interaction', async () => { - let interactionId; - { - 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; - } - - // 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); - console.log('result.data', result.data); - // FIXME: assert on result.data - } - }); - }); -}); // 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..e888984 --- /dev/null +++ b/test/mocha/30-interactions.js @@ -0,0 +1,110 @@ +/*! + * 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.skip('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(); + }); + + // FIXME: create associated workflow instance w/`inviteRequest` feature + // before() + 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.skip('creates a new interaction', async () => { + let interactionId; + { + 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; + } + + // 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); + console.log('result.data', result.data); + // FIXME: assert on result.data + } + }); +}); From 9f152d23df27c07468593a7c7809d43d08f85fd0 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Fri, 12 Sep 2025 13:44:00 -0400 Subject: [PATCH 14/17] Make interactions config reprocessable for testing purposes. --- lib/interactions.js | 85 ++++++++++++++++++++++----------------------- 1 file changed, 41 insertions(+), 44 deletions(-) diff --git a/lib/interactions.js b/lib/interactions.js index d2956e6..f43ac97 100644 --- a/lib/interactions.js +++ b/lib/interactions.js @@ -23,51 +23,10 @@ let DEFINITIONS_BY_ID_MAP; const POLL_TTL = 1000; bedrock.events.on('bedrock.init', () => { - 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); - } + processInteractionConfig(); }); bedrock.events.on('bedrock-express.configure.routes', app => { - const cfg = config['profile-http']; - - // interaction feature is optional, return early if not enabled - if(!cfg.interactions?.enabled) { - return; - } - const interactionsPath = '/interactions'; const routes = { interactions: interactionsPath, @@ -87,7 +46,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { const {id: accountId} = req.user.account || {}; const {type, exchange: {variables}} = req.body; - const definition = DEFINITIONS_BY_TYPE_MAP.get(type); + const definition = DEFINITIONS_BY_TYPE_MAP?.get(type); if(!definition) { throw new BedrockError(`Interaction type "${type}" not found.`, { name: 'NotFoundError', @@ -215,6 +174,44 @@ bedrock.events.on('bedrock-express.configure.routes', app => { })); }); +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, @@ -242,7 +239,7 @@ function _createExchangePoller({accountId, capability}) { } function _getInteractionDefinition({localInteractionId}) { - const definition = DEFINITIONS_BY_ID_MAP.get(localInteractionId); + const definition = DEFINITIONS_BY_ID_MAP?.get(localInteractionId); if(!definition) { throw new BedrockError( `Interaction type for "${localInteractionId}" not found.`, { From a6e1f9c2e09bacafb4e98f056cdea1d03824f322 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Fri, 12 Sep 2025 13:44:45 -0400 Subject: [PATCH 15/17] Add vc-workflow instance for testing interactions. --- test/mocha/30-interactions.js | 4 +- test/mocha/helpers.js | 2 +- test/mocha/mock.data.js | 7 ++- test/package.json | 6 +++ test/test.config.js | 9 ++-- test/test.js | 89 ++++++++++++++++++++++++++++++++++- 6 files changed, 106 insertions(+), 11 deletions(-) diff --git a/test/mocha/30-interactions.js b/test/mocha/30-interactions.js index e888984..9b4d6a5 100644 --- a/test/mocha/30-interactions.js +++ b/test/mocha/30-interactions.js @@ -12,7 +12,7 @@ let api; const baseURL = `https://${config.server.host}`; -describe.skip('interactions', () => { +describe('interactions', () => { // mock session authentication for delegations endpoint let passportStub; before(async () => { @@ -28,8 +28,6 @@ describe.skip('interactions', () => { passportStub.restore(); }); - // FIXME: create associated workflow instance w/`inviteRequest` feature - // before() it('fails to create a new interaction with bad post data', async () => { let result; let error; 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 d479b74..da2932c 100644 --- a/test/package.json +++ b/test/package.json @@ -28,18 +28,24 @@ "@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.0", "@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", diff --git a/test/test.config.js b/test/test.config.js index 9196ba7..37070eb 100644 --- a/test/test.config.js +++ b/test/test.config.js @@ -37,12 +37,12 @@ config['profile-http'].additionalEdvs = { credentials: {referenceId: 'credentials'}, }; -// enable optional `interactions` -config['profile-http'].interactions.enabled = true; +// `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: { - // FIXME: + // to be populated by `test.js` readWriteExchanges: '{}' } }; @@ -51,3 +51,6 @@ 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 e675ee0..68c7c91 100644 --- a/test/test.js +++ b/test/test.js @@ -2,7 +2,11 @@ * 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,14 +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 () => { @@ -38,5 +57,71 @@ 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: { + myStep: { + stepTemplate: { + type: 'jsonata', + template: ` + { + "inviteRequest": inviteRequest + }` + } + } + }, + initialStep: 'myStep' + }; + 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(); From c82b74e1ad376a51bb85cf1eee44910c0fc3c32c Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Fri, 12 Sep 2025 17:51:47 -0400 Subject: [PATCH 16/17] Add, improve, and pass interaction tests. --- lib/interactions.js | 19 ++--- schemas/bedrock-profile-http.js | 13 ++++ test/mocha/30-interactions.js | 102 ++++++++++++++++++++++++++- test/package.json | 2 +- test/test.js | 120 ++++++++++++++++---------------- 5 files changed, 181 insertions(+), 75 deletions(-) diff --git a/lib/interactions.js b/lib/interactions.js index f43ac97..d79e578 100644 --- a/lib/interactions.js +++ b/lib/interactions.js @@ -62,7 +62,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { // compute callback URL const {localInteractionId} = definition; - const callbackUrl = + const pushCallbackUrl = `${baseUri}${interactionsPath}/${localInteractionId}` + `/callbacks/${token}`; @@ -74,9 +74,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { // template variables variables: { ...variables, - callback: { - url: callbackUrl - }, + pushCallbackUrl, accountId } }; @@ -84,8 +82,8 @@ bedrock.events.on('bedrock-express.configure.routes', app => { const response = await zcapClient.write({json: exchange, capability}); const exchangeId = response.headers.get('location'); // reuse `localExchangeId` in path - const localExchangeId = exchangeId.slice(exchangeId.lastIndexOf('/')); - const id = `${config.server.baseUri}/${routes.interactions}/` + + const localExchangeId = exchangeId.slice(exchangeId.lastIndexOf('/') + 1); + const id = `${config.server.baseUri}${routes.interactions}/` + `${localInteractionId}/${localExchangeId}`; res.json({interactionId: id, exchangeId}); })); @@ -94,7 +92,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { app.get( routes.interaction, ensureAuthenticated, - // FIXME: add URL query validator that requires no query or `iuv=1` only + validate({querySchema: schemas.getInteractionQuery}), asyncHandler(async (req, res) => { const {id: accountId} = req.user.account || {}; const { @@ -139,7 +137,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { ttl: POLL_TTL }); - res.json(result); + res.json(result.value); })); // push event handler @@ -226,13 +224,10 @@ function _createExchangePoller({accountId, capability}) { } // return only information that should be accessible to client return { - exchange - // FIXME: filter info once final step name and info is determined - /* exchange: { state: exchange.state, result: exchange.variables.results?.finish - }*/ + } }; } }); diff --git a/schemas/bedrock-profile-http.js b/schemas/bedrock-profile-http.js index bed9b4e..6e350c3 100644 --- a/schemas/bedrock-profile-http.js +++ b/schemas/bedrock-profile-http.js @@ -198,11 +198,24 @@ const createInteraction = { } }; +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/30-interactions.js b/test/mocha/30-interactions.js index 9b4d6a5..b82c517 100644 --- a/test/mocha/30-interactions.js +++ b/test/mocha/30-interactions.js @@ -43,6 +43,7 @@ describe('interactions', () => { 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; @@ -64,8 +65,10 @@ describe('interactions', () => { result.data.message.should.equal( 'Interaction type "does-not-exist" not found.'); }); - it.skip('creates a new interaction', async () => { + + it('creates a new interaction', async () => { let interactionId; + let exchangeId; { let result; let error; @@ -86,6 +89,7 @@ describe('interactions', () => { should.exist(result.data.interactionId); should.exist(result.data.exchangeId); interactionId = result.data.interactionId; + exchangeId = result.data.exchangeId; } // get status of interaction @@ -101,8 +105,100 @@ describe('interactions', () => { should.exist(result); result.status.should.equal(200); result.ok.should.equal(true); - console.log('result.data', result.data); - // FIXME: assert on result.data + 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/package.json b/test/package.json index da2932c..d110768 100644 --- a/test/package.json +++ b/test/package.json @@ -40,7 +40,7 @@ "@bedrock/ssm-mongodb": "^13.0.0", "@bedrock/test": "^8.2.0", "@bedrock/validation": "^7.1.1", - "@bedrock/vc-delivery": "^7.7.0", + "@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", diff --git a/test/test.js b/test/test.js index 68c7c91..4b0e0af 100644 --- a/test/test.js +++ b/test/test.js @@ -58,69 +58,71 @@ bedrock.events.on('bedrock.init', async () => { }); 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; + // programmatically create workflow for interactions... - 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'}); + // 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 localWorkflowId = idEncoder.encode(await idGenerator.generate()); - const config = { - id: `${baseUri}/workflows/${localWorkflowId}`, - sequence: 0, - controller: capabilityAgent.id, - meterId, - steps: { - myStep: { - stepTemplate: { - type: 'jsonata', - template: ` - { - "inviteRequest": inviteRequest - }` - } + 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: 'myStep' - }; - await workflowService.configStorage.insert({config}); + } + }, + 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(); - } + // 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'; From b9988ec73e13c8a5eeba9c50cd3f017f025dc107 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Thu, 18 Sep 2025 00:06:26 -0400 Subject: [PATCH 17/17] Update copyright header dates. Co-authored-by: David I. Lehn --- lib/config.js | 2 +- lib/http.js | 2 +- lib/zcapClient.js | 2 +- test/test.config.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/config.js b/lib/config.js index af81d6f..0915c33 100644 --- a/lib/config.js +++ b/lib/config.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2024 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. */ import * as bedrock from '@bedrock/core'; diff --git a/lib/http.js b/lib/http.js index 0160d0e..4995bc5 100644 --- a/lib/http.js +++ b/lib/http.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2024 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'; diff --git a/lib/zcapClient.js b/lib/zcapClient.js index acc89a9..f0087e0 100644 --- a/lib/zcapClient.js +++ b/lib/zcapClient.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2024 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. */ import * as bedrock from '@bedrock/core'; import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020'; diff --git a/test/test.config.js b/test/test.config.js index 37070eb..959ed87 100644 --- a/test/test.config.js +++ b/test/test.config.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2024 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';