diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 4c11a89..2b15df5 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -70,3 +70,9 @@ jobs:
run: |
cd test
npm run coverage-ci
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v4
+ with:
+ file: ./test/coverage/lcov.info
+ fail_ci_if_error: true
+ token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bb173b3..1deba00 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,9 @@
credential request (i.e., including the `credential` param and
any other optional params) as an optional alternative to returning
only the value of the `credential` param for issuance.
+- Allow clients to provide local workflow IDs as long as they meet
+ the local ID validation requirements. This is to enable clients to
+ ensure that they do not create duplicate workflows.
## 4.3.0 - 2023-12-11
diff --git a/lib/index.js b/lib/index.js
index 9ec2b10..36bef7f 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -1,5 +1,5 @@
/*!
- * Copyright (c) 2022-2023 Digital Bazaar, Inc. All rights reserved.
+ * Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved.
*/
import * as bedrock from '@bedrock/core';
import * as exchangerSchemas from '../schemas/bedrock-vc-exchanger.js';
@@ -7,11 +7,13 @@ import {createService, schemas} from '@bedrock/service-core';
import {addRoutes} from './http.js';
import {initializeServiceAgent} from '@bedrock/service-agent';
import {klona} from 'klona';
+import {parseLocalId} from './helpers.js';
import '@bedrock/express';
// load config defaults
import './config.js';
+const routePrefix = '/exchangers';
const serviceType = 'vc-exchanger';
const {util: {BedrockError}} = bedrock;
@@ -31,10 +33,14 @@ bedrock.events.on('bedrock.init', async () => {
// schema.required.push('credentialTemplates');
}
+ // allow `id` property in `createConfigBody`, to be more rigorously validated
+ // below in `validateConfigFn`
+ createConfigBody.properties.id = updateConfigBody.properties.id;
+
// create `vc-exchanger` service
const service = await createService({
serviceType,
- routePrefix: '/exchangers',
+ routePrefix,
storageCost: {
config: 1,
revocation: 1
@@ -84,8 +90,25 @@ async function usageAggregator({meter, signal, service} = {}) {
return service.configStorage.getUsage({meterId, signal});
}
-async function validateConfigFn({config} = {}) {
+async function validateConfigFn({config, op} = {}) {
try {
+ // validate any `id` in a new config
+ if(op === 'create' && config.id !== undefined) {
+ try {
+ _validateId({id: config.id});
+ } catch(e) {
+ throw new BedrockError(
+ `Invalid client-provided configuration ID: ${e.message}.`, {
+ name: 'DataError',
+ details: {
+ httpStatusCode: 400,
+ public: true
+ },
+ cause: e
+ });
+ }
+ }
+
// if credential templates are specified, then `zcaps` MUST include at
// least `issue`
const {credentialTemplates = [], zcaps = {}} = config;
@@ -118,3 +141,29 @@ async function validateConfigFn({config} = {}) {
}
return {valid: true};
}
+
+function _validateId({id} = {}) {
+ // format: /
+
+ // ensure `id` starts with appropriate base URL
+ const {baseUri} = bedrock.config.server;
+ const base = `${baseUri}${routePrefix}/`;
+ if(id.startsWith(base)) {
+ // ensure `id` ends with appropriate local ID
+ const expectedLastSlashIndex = base.length - 1;
+ const idx = id.lastIndexOf('/');
+ if(idx === expectedLastSlashIndex) {
+ return parseLocalId({id});
+ }
+ }
+
+ throw new BedrockError(
+ `Configuration "id" must start with "${base}" and end in a multibase, ` +
+ 'base58-encoded local identifier.', {
+ name: 'DataError',
+ details: {
+ httpStatusCode: 400,
+ public: true
+ }
+ });
+}
diff --git a/test/mocha/10-provision.js b/test/mocha/10-provision.js
index b4659ec..4a0a523 100644
--- a/test/mocha/10-provision.js
+++ b/test/mocha/10-provision.js
@@ -1,5 +1,5 @@
/*!
- * Copyright (c) 2019-2022 Digital Bazaar, Inc. All rights reserved.
+ * Copyright (c) 2019-2024 Digital Bazaar, Inc. All rights reserved.
*/
import * as bedrock from '@bedrock/core';
import * as helpers from './helpers.js';
@@ -54,6 +54,28 @@ describe('provision', () => {
const {id: capabilityAgentId} = capabilityAgent;
result.controller.should.equal(capabilityAgentId);
});
+ it('creates a config with a client-chosen ID', async () => {
+ let err;
+ let result;
+ try {
+ const localId = await helpers.generateRandom();
+ result = await helpers.createExchangerConfig({
+ capabilityAgent, configOptions: {
+ id: `${mockData.baseUrl}/exchangers/${localId}`
+ }
+ });
+ } catch(e) {
+ err = e;
+ }
+ assertNoError(err);
+ should.exist(result);
+ result.should.have.keys([
+ 'controller', 'id', 'sequence', 'meterId'
+ ]);
+ result.sequence.should.equal(0);
+ const {id: capabilityAgentId} = capabilityAgent;
+ result.controller.should.equal(capabilityAgentId);
+ });
it('creates a config with only an issue zcap', async () => {
let err;
let result;
@@ -147,6 +169,55 @@ describe('provision', () => {
'A capability to issue credentials is required when credential ' +
'templates are provided.');
});
+ it('throws if duplicate client-chosen ID is used', async () => {
+ let err;
+ let result;
+ try {
+ // create config (should pass)
+ result = await helpers.createExchangerConfig({
+ capabilityAgent, configOptions: {
+ id: `${mockData.baseUrl}/exchangers/z1A183gxYRXYFUnHUXsS7KVmA`
+ }
+ });
+ should.exist(result);
+ // try to create duplicate (should throw)
+ result = undefined;
+ result = await helpers.createExchangerConfig({
+ capabilityAgent, configOptions: {
+ id: `${mockData.baseUrl}/exchangers/z1A183gxYRXYFUnHUXsS7KVmA`
+ }
+ });
+ } catch(e) {
+ err = e;
+ }
+ should.exist(err);
+ should.not.exist(result);
+ should.exist(err.data);
+ err.status.should.equal(409);
+ err.data.name.should.equal('DuplicateError');
+ err.data.message.should.contain('Duplicate configuration');
+ });
+ it('throws if invalid client-chosen ID is used', async () => {
+ let err;
+ let result;
+ try {
+ result = await helpers.createExchangerConfig({
+ capabilityAgent, configOptions: {
+ id: `${mockData.baseUrl}/exchangers/foo`
+ }
+ });
+ } catch(e) {
+ err = e;
+ }
+ should.exist(err);
+ should.not.exist(result);
+ should.exist(err.data);
+ err.status.should.equal(400);
+ err.data.name.should.equal('DataError');
+ err.data.message.should.contain('Configuration validation failed');
+ err.data.details.cause.message.should.contain(
+ 'Invalid client-provided configuration ID');
+ });
it('creates a config including proper ipAllowList', async () => {
const ipAllowList = ['127.0.0.1/32', '::1/128'];
diff --git a/test/mocha/helpers.js b/test/mocha/helpers.js
index a4f5c1e..f8d74c3 100644
--- a/test/mocha/helpers.js
+++ b/test/mocha/helpers.js
@@ -102,7 +102,7 @@ export async function createCredentialOffer({
};
if(preAuthorized) {
- exchange.openId.preAuthorizedCode = await _generateRandom();
+ exchange.openId.preAuthorizedCode = await generateRandom();
const grant = {'pre-authorized_code': exchange.openId.preAuthorizedCode};
offer.grants['urn:ietf:params:oauth:grant-type:pre-authorized_code'] =
grant;
@@ -172,10 +172,10 @@ export async function createConfig({
export async function createExchangerConfig({
capabilityAgent, ipAllowList, meterId, zcaps, credentialTemplates,
- steps, initialStep, oauth2 = false
+ steps, initialStep, oauth2 = false,
+ configOptions = {credentialTemplates, steps, initialStep}
} = {}) {
const url = `${mockData.baseUrl}/exchangers`;
- const configOptions = {credentialTemplates, steps, initialStep};
return createConfig({
serviceType: 'vc-exchanger',
url, capabilityAgent, ipAllowList, meterId, zcaps, configOptions, oauth2
@@ -462,6 +462,16 @@ export async function delegate({
});
}
+export function generateRandom() {
+ // 128-bit random number, base58 multibase + multihash encoded
+ return generateId({
+ bitLength: 128,
+ encoding: 'base58',
+ multibase: true,
+ multihash: true
+ });
+}
+
export async function getCredentialStatus({verifiableCredential}) {
// get SLC for the VC
const {credentialStatus} = verifiableCredential;
@@ -712,13 +722,3 @@ async function keyResolver({id}) {
const {data} = await httpClient.get(id, {agent: httpsAgent});
return data;
}
-
-function _generateRandom() {
- // 128-bit random number, base58 multibase + multihash encoded
- return generateId({
- bitLength: 128,
- encoding: 'base58',
- multibase: true,
- multihash: true
- });
-}