@bedrock/vc-delivery 5.1.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/exchanges.js CHANGED
@@ -3,18 +3,16 @@
3
3
  */
4
4
  import * as bedrock from '@bedrock/core';
5
5
  import * as database from '@bedrock/mongodb';
6
+ import {parseLocalId, stripStacktrace} from './helpers.js';
6
7
  import assert from 'assert-plus';
7
8
  import {logger} from './logger.js';
8
- import {parseLocalId} from './helpers.js';
9
+ import {serializeError} from 'serialize-error';
9
10
 
10
11
  const {util: {BedrockError}} = bedrock;
11
12
 
12
- /* Note: Exchanges either have TTLs and can be "completed" or they persist
13
- and never "complete" nor expire.
14
-
15
- Exchanges are always in one of three states: `pending`, `complete`, or
16
- `invalid`. They can only transistion from `pending` to `complete` or from
17
- `complete` to `invalid`.
13
+ /* Note: Exchanges have default TTLs of 15 minutes and are always in one of
14
+ four states: `pending`, `active`, `complete`, or `invalid`. They can only
15
+ transition from `pending` to `complete` or from `complete` to `invalid`.
18
16
 
19
17
  If an exchange is marked as complete, any attempt to mark it complete again
20
18
  will result in an action, as specified in the exchange record, being taken
@@ -28,6 +26,15 @@ provided during the exchange a capability to verify them must be provided. */
28
26
 
29
27
  const COLLECTION_NAME = 'vc-exchange';
30
28
 
29
+ // allow updates to the last error every 500ms
30
+ const LAST_ERROR_UPDATE_CONSTRAINTS = {
31
+ // if the exchange has been updated 5 or more times, apply the time limit
32
+ sequenceThreshold: 5,
33
+ // 1 second must expire before updating last error once sequence threshold
34
+ // has been hit
35
+ updateTimeLimit: 1000
36
+ };
37
+
31
38
  bedrock.events.on('bedrock-mongodb.ready', async () => {
32
39
  await database.openCollections([COLLECTION_NAME]);
33
40
 
@@ -88,9 +95,14 @@ export async function insert({workflowId, exchange}) {
88
95
  // build exchange record
89
96
  const now = Date.now();
90
97
  const meta = {created: now, updated: now};
98
+ // possible states are: `pending`, `active`, `complete`, or `invalid`
99
+ exchange = {...exchange, sequence: 0, state: 'pending'};
91
100
  if(exchange.ttl !== undefined) {
92
- // TTL is in seconds
93
- meta.expires = new Date(now + exchange.ttl * 1000);
101
+ // TTL is in seconds, convert to `expires`
102
+ const expires = new Date(now + exchange.ttl * 1000);
103
+ meta.expires = expires;
104
+ exchange.expires = expires.toISOString().replace(/\.\d+Z$/, 'Z');
105
+ delete exchange.ttl;
94
106
  }
95
107
  const {localId: localWorkflowId} = parseLocalId({id: workflowId});
96
108
  const record = {
@@ -98,8 +110,7 @@ export async function insert({workflowId, exchange}) {
98
110
  // backwards compatibility: enable existing systems to find record
99
111
  localExchangerId: localWorkflowId,
100
112
  meta,
101
- // possible states are: `pending`, `active`, `complete`, or `invalid`
102
- exchange: {...exchange, sequence: 0, state: 'pending'}
113
+ exchange
103
114
  };
104
115
 
105
116
  // insert the exchange and get the updated record
@@ -129,12 +140,16 @@ export async function insert({workflowId, exchange}) {
129
140
  * @param {string} options.workflowId - The ID of the workflow that the
130
141
  * exchange is associated with.
131
142
  * @param {string} options.id - The ID of the exchange to retrieve.
143
+ * @param {boolean} [options.allowExpired=false] - Controls whether an expired
144
+ * exchange that is still in the database can be retrieved or not.
132
145
  * @param {boolean} [options.explain=false] - An optional explain boolean.
133
146
  *
134
147
  * @returns {Promise<object | ExplainObject>} Resolves with the record that
135
148
  * matches the query or an ExplainObject if `explain=true`.
136
149
  */
137
- export async function get({workflowId, id, explain = false} = {}) {
150
+ export async function get({
151
+ workflowId, id, allowExpired = false, explain = false
152
+ } = {}) {
138
153
  assert.string(workflowId, 'workflowId');
139
154
  assert.string(id, 'id');
140
155
 
@@ -160,7 +175,16 @@ export async function get({workflowId, id, explain = false} = {}) {
160
175
  return cursor.explain('executionStats');
161
176
  }
162
177
 
163
- const record = await collection.findOne(query, {projection});
178
+ let record = await collection.findOne(query, {projection});
179
+ if(record?.exchange.expires && !allowExpired) {
180
+ // ensure `expires` is enforced programmatically even if background job
181
+ // has not yet removed the record
182
+ const now = new Date();
183
+ const expires = new Date(record.exchange.expires);
184
+ if(now >= expires) {
185
+ record = null;
186
+ }
187
+ }
164
188
  if(!record) {
165
189
  throw new BedrockError('Exchange not found.', {
166
190
  name: 'NotFoundError',
@@ -196,8 +220,8 @@ export async function get({workflowId, id, explain = false} = {}) {
196
220
  }
197
221
 
198
222
  /**
199
- * Updates a pending exchange with new state, variables, step, and TTL
200
- * information.
223
+ * Updates a pending or active exchange with new state, variables, step, and
224
+ * TTL, and error information.
201
225
  *
202
226
  * @param {object} options - The options to use.
203
227
  * @param {string} options.workflowId - The ID of the workflow the exchange
@@ -214,21 +238,7 @@ export async function update({workflowId, exchange, explain = false} = {}) {
214
238
  const {id} = exchange;
215
239
 
216
240
  // build update
217
- const now = Date.now();
218
- const update = {
219
- $inc: {'exchange.sequence': 1},
220
- $set: {'exchange.state': exchange.state, 'meta.updated': now}
221
- };
222
- // update exchange `variables`, `step`, and `ttl`
223
- if(exchange.variables) {
224
- update.$set['exchange.variables'] = exchange.variables;
225
- }
226
- if(exchange.step !== undefined) {
227
- update.$set['exchange.step'] = exchange.step;
228
- }
229
- if(exchange.ttl !== undefined) {
230
- update.$set['exchange.ttl'] = exchange.ttl;
231
- }
241
+ const update = _buildUpdate({exchange, complete: false});
232
242
 
233
243
  const {base, localId: localWorkflowId} = parseLocalId({id: workflowId});
234
244
 
@@ -304,27 +314,13 @@ export async function update({workflowId, exchange, explain = false} = {}) {
304
314
  export async function complete({workflowId, exchange, explain = false} = {}) {
305
315
  assert.string(workflowId, 'workflowId');
306
316
  assert.object(exchange, 'exchange');
317
+ if(exchange.state !== 'complete') {
318
+ throw new Error('"exchange.state" must be set to "complete".');
319
+ }
307
320
  const {id} = exchange;
308
321
 
309
322
  // build update
310
- const now = Date.now();
311
- const update = {
312
- $inc: {'exchange.sequence': 1},
313
- $set: {
314
- 'exchange.state': 'complete',
315
- 'meta.updated': now
316
- }
317
- };
318
- // update exchange `variables.results[step]`, `step`, and `ttl`
319
- if(exchange.variables?.results) {
320
- update.$set['exchange.variables.results'] = exchange.variables.results;
321
- }
322
- if(exchange.step !== undefined) {
323
- update.$set['exchange.step'] = exchange.step;
324
- }
325
- if(exchange.ttl !== undefined) {
326
- update.$set['exchange.ttl'] = exchange.ttl;
327
- }
323
+ const update = _buildUpdate({exchange, complete: true});
328
324
 
329
325
  const {base, localId: localWorkflowId} = parseLocalId({id: workflowId});
330
326
 
@@ -408,6 +404,105 @@ export async function complete({workflowId, exchange, explain = false} = {}) {
408
404
  });
409
405
  }
410
406
 
407
+ /**
408
+ * Sets the last error associated with an exchange, provided that the exchange
409
+ * has not been recently or frequently updated.
410
+ *
411
+ * @param {object} options - The options to use.
412
+ * @param {string} options.workflowId - The ID of the workflow the exchange
413
+ * is associated with.
414
+ * @param {object} options.exchange - The exchange to update with `lastError`
415
+ * set.
416
+ * @param {object} options.lastUpdated - The last update time (in milliseconds).
417
+ * @param {boolean} [options.explain=false] - An optional explain boolean.
418
+ *
419
+ * @returns {Promise<boolean | ExplainObject>} Resolves with `true` on update
420
+ * success or an ExplainObject if `explain=true`.
421
+ */
422
+ export async function setLastError({
423
+ workflowId, exchange, lastUpdated, explain = false
424
+ } = {}) {
425
+ assert.string(workflowId, 'workflowId');
426
+ assert.object(exchange, 'exchange');
427
+ assert.object(exchange.lastError, 'exchange.lastError');
428
+ assert.number(lastUpdated, 'lastUpdate');
429
+
430
+ // prevent too many updates to an exchange to write the last error to it
431
+ // by limiting to a few
432
+ const now = Date.now();
433
+ if(exchange.sequence > LAST_ERROR_UPDATE_CONSTRAINTS.sequenceThreshold &&
434
+ now < (lastUpdated + LAST_ERROR_UPDATE_CONSTRAINTS.updateTimeLimit)) {
435
+ // deny update, too many last error updates
436
+ return false;
437
+ }
438
+
439
+ // build update
440
+ const update = {
441
+ $inc: {'exchange.sequence': 1},
442
+ $set: {
443
+ 'meta.updated': now,
444
+ 'exchange.lastError': serializeError(stripStacktrace(exchange.lastError))
445
+ }
446
+ };
447
+
448
+ const {base, localId: localWorkflowId} = parseLocalId({id: workflowId});
449
+
450
+ const {id} = exchange;
451
+ const collection = database.collections[COLLECTION_NAME];
452
+ const query = {
453
+ localWorkflowId,
454
+ 'exchange.id': id,
455
+ // exchange sequence must match previous sequence
456
+ 'exchange.sequence': exchange.sequence - 1
457
+ };
458
+ // backwards compatibility: query on `localExchangerId`
459
+ if(base.endsWith('/exchangers')) {
460
+ query.localWorkflowId = {$in: [null, localWorkflowId]};
461
+ query.localExchangerId = localWorkflowId;
462
+ }
463
+
464
+ if(explain) {
465
+ // 'find().limit(1)' is used here because 'updateOne()' doesn't return a
466
+ // cursor which allows the use of the explain function.
467
+ const cursor = await collection.find(query).limit(1);
468
+ return cursor.explain('executionStats');
469
+ }
470
+
471
+ try {
472
+ const result = await collection.updateOne(query, update);
473
+ if(result.result.n > 0) {
474
+ // document modified: success
475
+ return true;
476
+ }
477
+ } catch(e) {
478
+ throw new BedrockError('Could not update exchange.', {
479
+ name: 'OperationError',
480
+ details: {
481
+ public: true,
482
+ httpStatusCode: 500
483
+ },
484
+ cause: e
485
+ });
486
+ }
487
+
488
+ // if no document was matched, try to get an existing exchange; if the
489
+ // exchange does not exist, a not found error will be automatically thrown
490
+ await get({workflowId, id});
491
+
492
+ /* Note: Here the exchange *does* exist, but the step or state did not
493
+ match which is a conflict error. */
494
+
495
+ // throw duplicate completed exchange error
496
+ throw new BedrockError('Could not update exchange; conflict error.', {
497
+ name: 'InvalidStateError',
498
+ details: {
499
+ public: true,
500
+ // this is a client-side conflict error
501
+ httpStatusCode: 409
502
+ }
503
+ });
504
+ }
505
+
411
506
  async function _invalidateExchange({record}) {
412
507
  try {
413
508
  // mark exchange invalid, but do not throw any error to client; only log it
@@ -463,6 +558,48 @@ async function _markExchangeInvalid({record}) {
463
558
  }
464
559
  }
465
560
 
561
+ function _buildUpdate({exchange, complete}) {
562
+ // build update
563
+ const now = Date.now();
564
+ const update = {
565
+ $inc: {'exchange.sequence': 1},
566
+ $set: {'exchange.state': exchange.state, 'meta.updated': now},
567
+ $unset: {}
568
+ };
569
+ if(complete) {
570
+ // exchange complete, only update results
571
+ if(exchange.variables?.results) {
572
+ update.$set['exchange.variables.results'] = exchange.variables.results;
573
+ }
574
+ } else {
575
+ // exchange not complete, update all variables
576
+ if(exchange.variables) {
577
+ update.$set['exchange.variables'] = exchange.variables;
578
+ }
579
+ }
580
+ if(exchange.step !== undefined) {
581
+ update.$set['exchange.step'] = exchange.step;
582
+ }
583
+ // only set `ttl` if expires not previously set / has been cleared
584
+ if(exchange.ttl !== undefined && exchange.expires === undefined) {
585
+ // TTL is in seconds, convert to expires
586
+ const expires = new Date(now + exchange.ttl * 1000);
587
+ // unset and previously set `ttl`
588
+ update.$unset['exchange.ttl'] = true;
589
+ update.$set['meta.expires'] = expires;
590
+ update.$set['exchange.expires'] =
591
+ expires.toISOString().replace(/\.\d+Z$/, 'Z');
592
+ }
593
+ if(exchange.lastError !== undefined) {
594
+ update.$set['exchange.lastError'] =
595
+ serializeError(stripStacktrace(exchange.lastError));
596
+ } else {
597
+ update.$unset['exchange.lastError'] = true;
598
+ }
599
+
600
+ return update;
601
+ }
602
+
466
603
  /**
467
604
  * An object containing information on the query plan.
468
605
  *
package/lib/helpers.js CHANGED
@@ -7,6 +7,7 @@ import {decodeId, generateId} from 'bnid';
7
7
  import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020';
8
8
  import {httpsAgent} from '@bedrock/https-agent';
9
9
  import jsonata from 'jsonata';
10
+ import {serializeError} from 'serialize-error';
10
11
  import {serviceAgents} from '@bedrock/service-agent';
11
12
  import {ZcapClient} from '@digitalbazaar/ezcap';
12
13
 
@@ -104,6 +105,21 @@ export function decodeLocalId({localId} = {}) {
104
105
  }));
105
106
  }
106
107
 
108
+ export function stripStacktrace(error) {
109
+ error = serializeError(error);
110
+ delete error.stack;
111
+ if(error.errors) {
112
+ error.errors = error.errors.map(stripStacktrace);
113
+ }
114
+ if(Array.isArray(error.details?.errors)) {
115
+ error.details.errors = error.details.errors.map(stripStacktrace);
116
+ }
117
+ if(error.cause) {
118
+ error.cause = stripStacktrace(error.cause);
119
+ }
120
+ return error;
121
+ }
122
+
107
123
  export async function unenvelopeCredential({
108
124
  envelopedCredential, format
109
125
  } = {}) {
package/lib/http.js CHANGED
@@ -113,8 +113,8 @@ export async function addRoutes({app, service} = {}) {
113
113
  getConfigMiddleware,
114
114
  asyncHandler(async (req, res) => {
115
115
  const {config: workflow} = req.serviceObject;
116
- const {exchange} = await req.getExchange();
117
- await processExchange({req, res, workflow, exchange});
116
+ const exchangeRecord = await req.getExchange();
117
+ await processExchange({req, res, workflow, exchangeRecord});
118
118
  }));
119
119
 
120
120
  // create OID4* routes to be used with each individual exchange
package/lib/oid4/http.js CHANGED
@@ -54,6 +54,7 @@ export async function createRoutes({
54
54
  ciMetadata2: `${exchangeRoute}/.well-known/openid-credential-issuer`,
55
55
  batchCredential: `${openIdRoute}/batch_credential`,
56
56
  credential: `${openIdRoute}/credential`,
57
+ credentialOffer: `${openIdRoute}/credential-offer`,
57
58
  token: `${openIdRoute}/token`,
58
59
  jwks: `${openIdRoute}/jwks`,
59
60
  // OID4VP routes
@@ -217,6 +218,18 @@ export async function createRoutes({
217
218
  });
218
219
  }));
219
220
 
221
+ // a credential delivery server endpoint
222
+ // serves the credential offer for all possible credentials in the exchange
223
+ app.get(
224
+ routes.credentialOffer,
225
+ cors(),
226
+ getConfigMiddleware,
227
+ getExchange,
228
+ asyncHandler(async (req, res) => {
229
+ const offer = await oid4vci.getCredentialOffer({req});
230
+ res.json(offer);
231
+ }));
232
+
220
233
  // a batch credential delivery server endpoint
221
234
  // receives N credential requests and returns N VCs
222
235
  app.options(routes.batchCredential, cors());