Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
22 changes: 21 additions & 1 deletion lib/config.js
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -47,3 +47,23 @@ const meterServiceName = `${namespace}.meterService`;
cc(`${meterServiceName}.url`, () => `${bedrock.config.server.baseUri}/meters`);
// ensure meter service config is overridden in deployments
config.ensureConfigOverride.fields.push(meterServiceName);

// optional interactions config
cfg.interactions = {
// FIXME: add qr-code route fallback for non-accept-json "protocols" requests
enabled: false,
// types of interactions, type name => definition
/* Spec:
{
<interaction type name>: {
...,
// a unique local interaction ID for use in interaction URLs
localInteractionId,
zcaps: {
readWriteExchanges: <zcap for reading/writing exchanges>
}
}
}
*/
types: {}
};
24 changes: 9 additions & 15 deletions lib/http.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,30 @@
/*!
* Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved.
* Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved.
*/
import * as bedrock from '@bedrock/core';
import * as schemas from '../schemas/bedrock-profile-http.js';
import {profileAgents, profileMeters, profiles} from '@bedrock/profile';
import {asyncHandler} from '@bedrock/express';
import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020';
import {ensureAuthenticated} from '@bedrock/passport';
import {getAppIdentity} from '@bedrock/app-identity';
import {httpsAgent} from '@bedrock/https-agent';
import {createValidateMiddleware as validate} from '@bedrock/validation';
import {ZcapClient} from '@digitalbazaar/ezcap';
import {ZCAP_CLIENT} from './zcapClient.js';

// include interactions routes
import './interactions.js';

const {config, util: {BedrockError}} = bedrock;

let APP_ID;
let EDV_METER_CREATION_ZCAP;
let WEBKMS_METER_CREATION_ZCAP;
let ZCAP_CLIENT;

bedrock.events.on('bedrock.init', () => {
// create signer using the application's capability invocation key
const {id, keys: {capabilityInvocationKey}} = getAppIdentity();
const {id} = getAppIdentity();
APP_ID = id;

ZCAP_CLIENT = new ZcapClient({
agent: httpsAgent,
invocationSigner: capabilityInvocationKey.signer(),
SuiteClass: Ed25519Signature2020
});

const cfg = bedrock.config['profile-http'];

const {edvMeterCreationZcap, webKmsMeterCreationZcap} = cfg;
if(edvMeterCreationZcap) {
EDV_METER_CREATION_ZCAP = JSON.parse(edvMeterCreationZcap);
Expand All @@ -48,8 +42,8 @@ bedrock.events.on('bedrock-express.configure.routes', app => {
const profileAgentPath = `${profileAgentsPath}/:profileAgentId`;
const routes = {
profiles: basePath,
profileAgents: `${profileAgentsPath}`,
profileAgent: `${profileAgentPath}`,
profileAgents: profileAgentsPath,
profileAgent: profileAgentPath,
profileAgentClaim: `${profileAgentPath}/claim`,
profileAgentCapabilities: `${profileAgentPath}/capabilities/delegate`,
profileAgentCapabilitySet: `${profileAgentPath}/capability-set`
Expand Down
246 changes: 246 additions & 0 deletions lib/interactions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
/*!
* Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved.
*/
import * as bedrock from '@bedrock/core';
import * as schemas from '../schemas/bedrock-profile-http.js';
import {poll, pollers, push} from '@bedrock/notify';
import {agent} from '@bedrock/https-agent';
import {asyncHandler} from '@bedrock/express';
import {ensureAuthenticated} from '@bedrock/passport';
import {httpClient} from '@digitalbazaar/http-client';
import {createValidateMiddleware as validate} from '@bedrock/validation';
import {ZCAP_CLIENT as zcapClient} from './zcapClient.js';

const {config, util: {BedrockError}} = bedrock;

let DEFINITIONS_BY_TYPE_MAP;
let DEFINITIONS_BY_ID_MAP;

// use a TTL of 1 second to account for the case where a push notification
// isn't received by the same instance that the client hits, but prevent
// requests from triggering a hit to the workflow service backend more
// frequently than 1 second
const POLL_TTL = 1000;

bedrock.events.on('bedrock.init', () => {
processInteractionConfig();
});

bedrock.events.on('bedrock-express.configure.routes', app => {
const interactionsPath = '/interactions';
const routes = {
interactions: interactionsPath,
interaction: `${interactionsPath}/:localInteractionId/:localExchangeId`,
callback: `${interactionsPath}/:localInteractionId/callbacks/:pushToken`
};

// base URL for server
const {baseUri} = bedrock.config.server;

// create an interaction to exchange VCs
app.post(
routes.interactions,
ensureAuthenticated,
validate({bodySchema: schemas.createInteraction}),
asyncHandler(async (req, res) => {
const {id: accountId} = req.user.account || {};
const {type, exchange: {variables}} = req.body;

const definition = DEFINITIONS_BY_TYPE_MAP?.get(type);
if(!definition) {
throw new BedrockError(`Interaction type "${type}" not found.`, {
name: 'NotFoundError',
details: {
httpStatusCode: 404,
public: true
}
});
}

// create a push token
const {token} = await push.createPushToken({event: 'exchangeUpdated'});

// compute callback URL
const {localInteractionId} = definition;
const pushCallbackUrl =
`${baseUri}${interactionsPath}/${localInteractionId}` +
`/callbacks/${token}`;

// create exchange with given variables
const exchange = {
// FIXME: use `expires` instead of now-deprecated `ttl`
// 15 minute expiry in seconds
ttl: 60 * 15,
// template variables
variables: {
...variables,
pushCallbackUrl,
accountId
}
};
const capability = definition.zcaps.get('readWriteExchanges');
const response = await zcapClient.write({json: exchange, capability});
const exchangeId = response.headers.get('location');
// reuse `localExchangeId` in path
const localExchangeId = exchangeId.slice(exchangeId.lastIndexOf('/') + 1);
const id = `${config.server.baseUri}${routes.interactions}/` +
`${localInteractionId}/${localExchangeId}`;
res.json({interactionId: id, exchangeId});
}));

// gets an interaction
app.get(
routes.interaction,
ensureAuthenticated,
validate({querySchema: schemas.getInteractionQuery}),
asyncHandler(async (req, res) => {
const {id: accountId} = req.user.account || {};
const {
params: {localInteractionId, localExchangeId},
query: {iuv}
} = req;

// get interaction definition
const definition = _getInteractionDefinition({localInteractionId});

// determine full exchange ID based on related capability
const capability = definition.zcaps.get('readWriteExchanges');
const exchangeId = `${capability.invocationTarget}/${localExchangeId}`;

// if an "Interaction URL Version" is present send "protocols"
// (note: validation requires it to be `1`, so no need to check its value)
if(iuv) {
// FIXME: send to a QR-code page if supported
// FIXME: check config for supported QR code route and use it
// instead of hard-coded value
if(req.accepts('html') || !req.accepts('json')) {
return res.redirect(`${req.originalUrl}/qr-code`);
}
try {
const url = `${exchangeId}/protocols`;
const {data: protocols} = await httpClient.get(url, {agent});
res.json(protocols);
} catch(cause) {
throw new BedrockError(
'Unable to serve protocols object: ' + cause.message, {
name: 'OperationError',
details: {httpStatusCode: 500, public: true},
cause
});
}
}

// poll the exchange...
const result = await poll({
id: exchangeId,
poller: _createExchangePoller({accountId, capability}),
ttl: POLL_TTL
});

res.json(result.value);
}));

// push event handler
app.post(
routes.callback,
push.createVerifyPushTokenMiddleware({event: 'exchangeUpdated'}),
asyncHandler(async (req, res) => {
const {event: {data: {exchangeId: id}}} = req.body;
const {localInteractionId} = req.params;

// get interaction definition
const definition = _getInteractionDefinition({localInteractionId});

// get capability for fetching exchange and verify its invocation target
// matches the exchange ID passed
const capability = definition.zcaps.get('readWriteExchanges');
if(!id.startsWith(capability.invocationTarget)) {
throw new BedrockError('Not authorized.', {
name: 'NotAllowedError',
details: {httpStatusCode: 403, public: true}
});
}

// poll (and clear cache)
await poll({
id,
poller: _createExchangePoller({capability}),
ttl: POLL_TTL,
useCache: false
});
res.sendStatus(204);
}));
});

export function processInteractionConfig() {
const cfg = config['profile-http'];

// interactions feature is optional, return early if not enabled
if(!cfg.interactions?.enabled) {
return;
}

// parse interaction types when enabled
const {types = {}} = cfg.interactions;
DEFINITIONS_BY_TYPE_MAP = new Map();
DEFINITIONS_BY_ID_MAP = new Map();
for(const typeName in types) {
const {localInteractionId, zcaps} = types[typeName];
if(!localInteractionId) {
throw new TypeError(
'"bedrock.config.profile-http.interaction.types" must each ' +
'have "localInteractionId".');
}
const definition = {
name: typeName,
localInteractionId,
zcaps: new Map()
};
for(const zcapName in zcaps) {
const zcap = zcaps[zcapName];
definition.zcaps.set(zcapName, JSON.parse(zcap));
}
if(!definition.zcaps.has('readWriteExchanges')) {
throw new TypeError(
'"bedrock.config.profile-http.interaction.types" must each ' +
'have "zcaps.readWriteExchanges".');
}
DEFINITIONS_BY_TYPE_MAP.set(typeName, definition);
DEFINITIONS_BY_ID_MAP.set(localInteractionId, definition);
}
}

function _createExchangePoller({accountId, capability}) {
return pollers.createExchangePoller({
zcapClient,
capability,
filterExchange({exchange/*, previousPollResult*/}) {
// if `accountId` given, ensure it matches exchange variables
if(accountId && exchange?.variables.accountId !== accountId) {
throw new BedrockError('Not authorized.', {
name: 'NotAllowedError',
details: {httpStatusCode: 403, public: true}
});
}
// return only information that should be accessible to client
return {
exchange: {
state: exchange.state,
result: exchange.variables.results?.finish
}
};
}
});
}

function _getInteractionDefinition({localInteractionId}) {
const definition = DEFINITIONS_BY_ID_MAP?.get(localInteractionId);
if(!definition) {
throw new BedrockError(
`Interaction type for "${localInteractionId}" not found.`, {
name: 'NotFoundError',
details: {httpStatusCode: 404, public: true}
});
}
return definition;
}
21 changes: 21 additions & 0 deletions lib/zcapClient.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*!
* Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved.
*/
import * as bedrock from '@bedrock/core';
import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020';
import {getAppIdentity} from '@bedrock/app-identity';
import {httpsAgent} from '@bedrock/https-agent';
import {ZcapClient} from '@digitalbazaar/ezcap';

export let ZCAP_CLIENT;

bedrock.events.on('bedrock.init', () => {
// create signer using the application's capability invocation key
const {keys: {capabilityInvocationKey}} = getAppIdentity();

ZCAP_CLIENT = new ZcapClient({
agent: httpsAgent,
invocationSigner: capabilityInvocationKey.signer(),
SuiteClass: Ed25519Signature2020
});
});
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,15 @@
"homepage": "https://github.com/digitalbazaar/bedrock-profile-http",
"dependencies": {
"@digitalbazaar/ed25519-signature-2020": "^5.4.0",
"@digitalbazaar/ezcap": "^4.1.0"
"@digitalbazaar/ezcap": "^4.1.0",
"@digitalbazaar/http-client": "^4.2.0"
},
"peerDependencies": {
"@bedrock/app-identity": "^4.0.0",
"@bedrock/core": "^6.3.0",
"@bedrock/express": "^8.3.1",
"@bedrock/https-agent": "^4.1.0",
"@bedrock/notify": "^1.1.0",
"@bedrock/passport": "^12.0.0",
"@bedrock/profile": "^26.0.0",
"@bedrock/validation": "^7.1.1"
Expand Down
Loading