@bedrock/vc-delivery
Version:
Bedrock Verifiable Credential Delivery
638 lines (579 loc) • 21.2 kB
JavaScript
/*!
* Copyright (c) 2022-2026 Digital Bazaar, Inc.
*/
import * as bedrock from '@bedrock/core';
import * as database from '@bedrock/mongodb';
import {decodeVariables, encodeVariables} from './variables.js';
import {
EXCHANGE_EXPIRY_GRACE_PERIOD, EXCHANGE_TTL_DEFAULT
} from '../constants.js';
import {parseLocalId, stripStacktrace} from '../helpers.js';
import assert from 'assert-plus';
import {logger} from '../logger.js';
import {serializeError} from 'serialize-error';
// ensure externalized `variables` garbage collector runs
import './variablesGarbageCollector.js';
const {util: {BedrockError}} = bedrock;
/* Note: Exchanges have default TTLs of 15 minutes and are always in one of
four states: `pending`, `active`, `complete`, or `invalid`. They can only
transition from `pending` to `complete` or from `complete` to `invalid`.
If an exchange is marked as complete, any attempt to mark it complete again
will result in an action, as specified in the exchange record, being taken
such as auto-revocation or notification.
Each pending exchange is an instance of a workflow. A workflow may have
one or more steps, each that might issue, verify, or deliver VCs. Capabilities
must be provided to issue or verify VCs. */
const COLLECTION_NAME = 'vc-exchange';
const BUCKET_NAME = 'vc-exchange-variables';
// allow updates to the last error every 500ms
const LAST_ERROR_UPDATE_CONSTRAINTS = {
// if the exchange has been updated 5 or more times, apply the time limit
sequenceThreshold: 5,
// 1 second must expire before updating last error once sequence threshold
// has been hit
updateTimeLimit: 1000
};
bedrock.events.on('bedrock-mongodb.ready', async () => {
await database.openCollections([
COLLECTION_NAME, `${BUCKET_NAME}.files`
]);
await database.createIndexes([{
// cover exchange queries by local workflow ID + exchange ID
collection: COLLECTION_NAME,
fields: {localWorkflowId: 1, 'exchange.id': 1},
options: {
partialFilterExpression: {
localWorkflowId: {$exists: true}
},
unique: true
}
}, {
// backwards compatibility: cover exchange queries by
// local exchanger ID + exchange ID; local exchanger ID is the same as
// local workflow ID and this index can be eventually dropped once no
// deployments use `localExchangerId`
collection: COLLECTION_NAME,
fields: {localExchangerId: 1, 'exchange.id': 1},
options: {unique: true}
}, {
// expire exchanges based on `expires` field
collection: COLLECTION_NAME,
fields: {'meta.expires': 1},
options: {
partialFilterExpression: {
'meta.expires': {$exists: true}
},
unique: false,
expireAfterSeconds: 0
}
}]);
});
/**
* Inserts an exchange record.
*
* @param {object} options - The options to use.
* @param {string} options.workflowId - The ID of the workflow that the
* exchange is associated with.
* @param {object} options.exchange - The exchange to insert.
*
* @returns {Promise<object>} Resolves to the database record.
*/
export async function insert({workflowId, exchange}) {
assert.string(workflowId, 'workflowId');
assert.object(exchange, 'exchange');
assert.string(exchange.id, 'exchange.id');
assert.string(exchange.expires, 'exchange.expires');
// optional time to live in seconds
assert.optionalNumber(exchange.ttl, 'exchange.ttl');
// optional variables to use in VC templates
assert.optionalObject(exchange.variables, 'exchange.variables');
// optional current step in the exchange
assert.optionalString(exchange.step, 'exchange.step');
// optional protocols in exchange
assert.optionalObject(exchange.protocols, 'exchange.protocols');
// build exchange record
const now = Date.now();
const meta = {
created: now,
updated: now,
expires: new Date(exchange.expires)
};
// possible states are: `pending`, `active`, `complete`, or `invalid`
exchange = {...exchange, sequence: 0, state: 'pending'};
const {localId: localWorkflowId} = parseLocalId({id: workflowId});
const record = {
localWorkflowId,
// backwards compatibility: enable existing systems to find record
localExchangerId: localWorkflowId,
meta,
exchange: await encodeVariables({workflowId, exchange, meta})
};
// insert the exchange and get the updated record
try {
const collection = database.collections[COLLECTION_NAME];
await collection.insertOne(record);
return record;
} catch(e) {
if(!database.isDuplicateError(e)) {
throw e;
}
throw new BedrockError('Duplicate document.', {
name: 'DuplicateError',
details: {
public: true,
httpStatusCode: 409
},
cause: e
});
}
}
/**
* Gets an exchange record.
*
* @param {object} options - The options to use.
* @param {string} options.workflowId - The ID of the workflow that the
* exchange is associated with.
* @param {string} options.id - The ID of the exchange to retrieve.
* @param {boolean} [options.allowExpired=false] - Controls whether an expired
* exchange that is still in the database can be retrieved or not.
* @param {boolean} [options.explain=false] - An optional explain boolean.
*
* @returns {Promise<object | ExplainObject>} Resolves with the record that
* matches the query or an ExplainObject if `explain=true`.
*/
export async function get({
workflowId, id, allowExpired = false, explain = false
} = {}) {
assert.string(workflowId, 'workflowId');
assert.string(id, 'id');
const {base, localId: localWorkflowId} = parseLocalId({id: workflowId});
const collection = database.collections[COLLECTION_NAME];
const query = {
localWorkflowId,
'exchange.id': id,
// treat exchange as not found if invalid
'exchange.state': {$ne: 'invalid'}
};
// backwards compatibility: query on `localExchangerId`
if(base.endsWith('/exchangers')) {
query.localWorkflowId = {$in: [null, localWorkflowId]};
query.localExchangerId = localWorkflowId;
}
const projection = {_id: 0, exchange: 1, meta: 1};
if(explain) {
// 'find().limit(1)' is used here because 'findOne()' doesn't return a
// cursor which allows the use of the explain function.
const cursor = await collection.find(query, {projection}).limit(1);
return cursor.explain('executionStats');
}
let record = await collection.findOne(query, {projection});
if(record && !allowExpired) {
// ensure `expires` is enforced programmatically even if background job
// has not yet removed the record; force unexpiring exchanges to be not
// found via this code path -- any exchanges without an expiration date
// are from very old software and they will need to be manually cleaned up
// in the database
const now = new Date();
// note: for undefined `expires`, this will be `NaN || now` => `now`
const expires = new Date(Date.parse(record.exchange.expires) || now);
if(now >= expires) {
record = null;
}
}
if(!record) {
throw new BedrockError('Exchange not found.', {
name: 'NotFoundError',
details: {
workflow: workflowId,
// backwards compatibility
exchanger: workflowId,
exchange: id,
httpStatusCode: 404,
public: true
}
});
}
record.exchange = await decodeVariables({workflowId, record});
// backwards compatibility; initialize `sequence`
if(record.exchange.sequence === undefined) {
const query = {
localWorkflowId,
'exchange.id': id,
'exchange.sequence': null
};
// backwards compatibility: query on `localExchangerId`
if(base.endsWith('/exchangers')) {
query.localWorkflowId = {$in: [null, localWorkflowId]};
query.localExchangerId = localWorkflowId;
}
await collection.updateOne(query, {$set: {'exchange.sequence': 0}});
record.exchange.sequence = 0;
}
return record;
}
/**
* Updates a pending or active exchange with new state, variables, step, and
* TTL, and error information.
*
* @param {object} options - The options to use.
* @param {string} options.workflowId - The ID of the workflow the exchange
* is associated with.
* @param {object} options.exchange - The exchange to update.
* @param {object} options.meta - The exchange meta to update.
* @param {boolean} [options.explain=false] - An optional explain boolean.
*
* @returns {Promise<boolean | ExplainObject>} Resolves with `true` on update
* success or an ExplainObject if `explain=true`.
*/
export async function update({
workflowId, exchange, meta, explain = false
} = {}) {
assert.string(workflowId, 'workflowId');
assert.object(exchange, 'exchange');
const {id} = exchange;
// force exchange to expire if this code has been called on an old exchange
// with no expiration date
let updateExpires = false;
if(exchange.expires === undefined) {
updateExpires = true;
const ttl = exchange.ttl ?? EXCHANGE_TTL_DEFAULT;
// TTL is in seconds, convert to milliseconds
const expires = new Date(Date.now() + ttl * 1000);
exchange.expires = expires.toISOString().replace(/\.\d+Z$/, 'Z');
}
// encode variable content for storage in mongoDB
meta = {...meta};
exchange = await encodeVariables({workflowId, exchange, meta});
// build update
const update = _buildUpdate({exchange, meta, updateExpires});
const {base, localId: localWorkflowId} = parseLocalId({id: workflowId});
const collection = database.collections[COLLECTION_NAME];
const query = {
localWorkflowId,
'exchange.id': id,
// exchange sequence must match previous sequence
'exchange.sequence': exchange.sequence - 1,
// previous state must be `pending` or `active` in order to update it
'exchange.state': {$in: ['pending', 'active']}
};
// backwards compatibility: query on `localExchangerId`
if(base.endsWith('/exchangers')) {
query.localWorkflowId = {$in: [null, localWorkflowId]};
query.localExchangerId = localWorkflowId;
}
if(explain) {
// 'find().limit(1)' is used here because 'updateOne()' doesn't return a
// cursor which allows the use of the explain function.
const cursor = await collection.find(query).limit(1);
return cursor.explain('executionStats');
}
try {
const result = await collection.updateOne(query, update);
if(result.modifiedCount > 0) {
// document modified: success
return true;
}
} catch(e) {
throw new BedrockError('Could not update exchange.', {
name: 'OperationError',
details: {
public: true,
httpStatusCode: 500
},
cause: e
});
}
// if no document was matched, try to get an existing exchange; if the
// exchange does not exist, a not found error will be automatically thrown
await get({workflowId, id});
/* Note: Here the exchange *does* exist, but the step or state did not
match which is a conflict error. */
// throw duplicate completed exchange error
throw new BedrockError('Could not update exchange; conflict error.', {
name: 'InvalidStateError',
details: {
public: true,
// this is a client-side conflict error
httpStatusCode: 409
}
});
}
/**
* Marks an exchange as complete.
*
* @param {object} options - The options to use.
* @param {string} options.workflowId - The ID of the workflow the exchange
* is associated with.
* @param {object} options.exchange - The exchange to mark as complete.
* @param {boolean} [options.explain=false] - An optional explain boolean.
*
* @returns {Promise<boolean | ExplainObject>} Resolves with `true` on update
* success or an ExplainObject if `explain=true`.
*/
export async function complete({workflowId, exchange, explain = false} = {}) {
assert.string(workflowId, 'workflowId');
assert.object(exchange, 'exchange');
if(exchange.state !== 'complete') {
throw new Error('"exchange.state" must be set to "complete".');
}
const {id} = exchange;
// build update
const update = _buildUpdate({exchange});
const {base, localId: localWorkflowId} = parseLocalId({id: workflowId});
const collection = database.collections[COLLECTION_NAME];
const query = {
localWorkflowId,
'exchange.id': id,
// exchange sequence must match previous sequence
'exchange.sequence': exchange.sequence - 1,
// previous state must be `pending` or `active` in order to change to
// `complete`
'exchange.state': {$in: ['pending', 'active']}
};
// backwards compatibility: query on `localExchangerId`
if(base.endsWith('/exchangers')) {
query.localWorkflowId = {$in: [null, localWorkflowId]};
query.localExchangerId = localWorkflowId;
}
if(explain) {
// 'find().limit(1)' is used here because 'updateOne()' doesn't return a
// cursor which allows the use of the explain function.
const cursor = await collection.find(query).limit(1);
return cursor.explain('executionStats');
}
try {
const result = await collection.updateOne(query, update);
if(result.modifiedCount > 0) {
// document modified: success
return true;
}
} catch(e) {
throw new BedrockError('Could not complete exchange.', {
name: 'OperationError',
details: {
public: true,
httpStatusCode: 500
},
cause: e
});
}
// if no document was matched, try to get an existing exchange; if the
// exchange does not exist, a not found error will be automatically thrown
const record = await get({workflowId, id});
/* Note: Here the exchange *does* exist, but couldn't be updated because
another process changed it. That change either left it in a still pending
state or it was already completed. If it was already completed, it is an
error condition that must result in invalidating the exchange. */
if(record.exchange.state === 'pending' ||
record.exchange.state === 'active') {
// exchange still pending, another process updated it
throw new BedrockError('Could not update exchange; conflict error.', {
name: 'InvalidStateError',
details: {
public: true,
// this is a client-side conflict error
httpStatusCode: 409
}
});
}
// state is either `complete` or `invalid`, so throw duplicate completed
// exchange error and invalidate exchange if needed, but do not throw any
// error to client; only log it
if(record.exchange.state !== 'invalid') {
_invalidateExchange({record}).catch(
error => logger.error(`Could not invalidate exchange "${id}".`, {error}));
}
// throw duplicate completed exchange error
throw new BedrockError('Could not complete exchange; already completed.', {
name: 'DuplicateError',
details: {
public: true,
// this is a client-side conflict error
httpStatusCode: 409
}
});
}
/**
* Sets the last error associated with an exchange, provided that the exchange
* has not been recently or frequently updated.
*
* @param {object} options - The options to use.
* @param {string} options.workflowId - The ID of the workflow the exchange
* is associated with.
* @param {object} options.exchange - The exchange to update with `lastError`
* set.
* @param {object} options.lastUpdated - The last update time (in milliseconds).
* @param {boolean} [options.explain=false] - An optional explain boolean.
*
* @returns {Promise<boolean | ExplainObject>} Resolves with `true` on update
* success or an ExplainObject if `explain=true`.
*/
export async function setLastError({
workflowId, exchange, lastUpdated, explain = false
} = {}) {
assert.string(workflowId, 'workflowId');
assert.object(exchange, 'exchange');
assert.object(exchange.lastError, 'exchange.lastError');
assert.number(lastUpdated, 'lastUpdate');
// prevent too many updates to an exchange to write the last error to it
// by limiting to a few
const now = Date.now();
if(exchange.sequence > LAST_ERROR_UPDATE_CONSTRAINTS.sequenceThreshold &&
now < (lastUpdated + LAST_ERROR_UPDATE_CONSTRAINTS.updateTimeLimit)) {
// deny update, too many last error updates
return false;
}
// build update
const update = {
$inc: {'exchange.sequence': 1},
$set: {
'meta.updated': now,
'exchange.lastError': serializeError(stripStacktrace(exchange.lastError))
}
};
const {base, localId: localWorkflowId} = parseLocalId({id: workflowId});
const {id} = exchange;
const collection = database.collections[COLLECTION_NAME];
const query = {
localWorkflowId,
'exchange.id': id,
// exchange sequence must match previous sequence
'exchange.sequence': exchange.sequence - 1
};
// backwards compatibility: query on `localExchangerId`
if(base.endsWith('/exchangers')) {
query.localWorkflowId = {$in: [null, localWorkflowId]};
query.localExchangerId = localWorkflowId;
}
if(explain) {
// 'find().limit(1)' is used here because 'updateOne()' doesn't return a
// cursor which allows the use of the explain function.
const cursor = await collection.find(query).limit(1);
return cursor.explain('executionStats');
}
try {
const result = await collection.updateOne(query, update);
if(result.modifiedCount > 0) {
// document modified: success
return true;
}
} catch(e) {
throw new BedrockError('Could not update exchange.', {
name: 'OperationError',
details: {
public: true,
httpStatusCode: 500
},
cause: e
});
}
// if no document was matched, try to get an existing exchange; if the
// exchange does not exist, a not found error will be automatically thrown
await get({workflowId, id});
/* Note: Here the exchange *does* exist, but the step or state did not
match which is a conflict error. */
// throw duplicate completed exchange error
throw new BedrockError('Could not update exchange; conflict error.', {
name: 'InvalidStateError',
details: {
public: true,
// this is a client-side conflict error
httpStatusCode: 409
}
});
}
async function _invalidateExchange({record}) {
try {
// mark exchange invalid, but do not throw any error to client; only log it
await _markExchangeInvalid({record});
} catch(error) {
logger.error(
`Could not mark exchange "${record.exchange.id}" invalid.`, {error});
}
/* Consider perform auto-revocation of the VCs or notification (the action to
take is specified in the exchange record). */
// FIXME: handle auto-revocation / notification in background; do not throw
// errors to client; consider removing invalidation state as well and rely
// solely on TTL to manage exchange secrecy/abuse
}
async function _markExchangeInvalid({record}) {
const now = Date.now();
// mark exchange invalid
try {
const query = {
localWorkflowId: record.localWorkflowId,
'exchange.id': record.exchange.id
};
// backwards compatibility: query on `localExchangerId`
if(!record.localWorkflowId) {
query.localExchangerId = record.localExchangerId;
}
const update = {
$set: {
'exchange.state': 'invalid',
'meta.updated': now,
'meta.expires': new Date(now + EXCHANGE_EXPIRY_GRACE_PERIOD)
}
};
const collection = database.collections[COLLECTION_NAME];
const result = await collection.updateOne(query, update);
if(result.modifiedCount > 0) {
// document modified: success
return true;
}
} catch(e) {
throw new BedrockError('Could not mark exchange invalid.', {
name: 'OperationError',
details: {
public: true,
httpStatusCode: 500
},
cause: e
});
}
}
function _buildUpdate({exchange, meta, updateExpires = false}) {
// build update
const now = Date.now();
const update = {
$inc: {'exchange.sequence': 1},
$set: {
'exchange.referenceId': exchange.referenceId ?? exchange.id,
'exchange.state': exchange.state,
'exchange.secrets': exchange.secrets,
'exchange.variables': exchange.variables,
'meta.updated': now
},
$unset: {}
};
if(exchange.step !== undefined) {
update.$set['exchange.step'] = exchange.step;
}
// only update (fix) `expires` if it was not previously set (a very old
// exchange is being updated)
if(updateExpires) {
// TTL is in seconds, convert to milliseconds
const expires = new Date(exchange.expires);
// unset any previously set `ttl` and set `expires` instead
update.$unset['exchange.ttl'] = true;
update.$set['meta.expires'] = expires;
update.$set['exchange.expires'] = exchange.expires;
}
if(exchange.lastError !== undefined) {
update.$set['exchange.lastError'] =
serializeError(stripStacktrace(exchange.lastError));
} else {
update.$unset['exchange.lastError'] = true;
}
if(meta?.variablesFilename === false) {
update.$unset['meta.variablesFilename'] = true;
} else if(typeof meta?.variablesFilename === 'string') {
update.$set['meta.variablesFilename'] = meta.variablesFilename;
}
return update;
}
/**
* An object containing information on the query plan.
*
* @typedef {object} ExplainObject
*/