@bedrock/web-vc-store
Version:
A Javascript library for storing Verifiable Credentials for Bedrock web apps.
962 lines (873 loc) • 34.5 kB
JavaScript
/*!
* Copyright (c) 2019-2022 Digital Bazaar, Inc. All rights reserved.
*/
import * as assert from './assert.js';
import canonicalize from 'canonicalize';
import {LruCache} from '@digitalbazaar/lru-memoize';
import pAll from 'p-all';
import {v4 as uuid} from 'uuid';
// `10` is chosen as the concurrency value because it is the lowest power of
// ten that is an acceptable number of concurrent web requests when pipelining
// may not be available (for remote credential stores)
const OPS_CONCURRENCY = 10;
/**
* Each instance of this API is associated with a single EDV client and
* performs initialization (ensures required indexes are created).
*/
export class VerifiableCredentialStore {
/**
* Creates a `VerifiableCredentialStore` interface for accessing a VC store
* in an EDV (Encrypted Data Vault).
*
* @param {object} options - The options to use.
* @param {*} options.edv - A removed parameter, DO NOT USE.
* @param {*} options.capability - A removed parameter, DO NOT USE.
* @param {*} options.invocationSigner - A removed parameter, DO NOT USE.
* @param {object} options.edvClient - An `EdvClient` instance to use.
* @param {boolean} [options.addBundleContentsFirst=false] - Sets whether
* the credential or its bundle contents will be added (inserted or
* upserted) first by default; this flag is useful for remedying partial
* bundle updates -- by default the credential is added first to help
* ensure its contents can be found by the bundling VC; adding the contents
* first can however be useful for workflows where bundled content is the
* primary search target and the parent bundler can then be discovered from
* the meta data even if it wasn't successfully added.
*/
constructor({
edv, edvClient, capability, invocationSigner,
addBundleContentsFirst = false
} = {}) {
// throw on old parameters
if(edv !== undefined) {
throw new Error(
'"edv" is no longer supported, pass "edvClient" instead.');
}
if(capability !== undefined) {
throw new Error(
'"capability" is no longer supported, pass an "edvClient" instance ' +
'that internalizes zcap processing instead.');
}
if(invocationSigner !== undefined) {
throw new Error(
'"invocationSigner" is no longer supported, pass "edvClient" ' +
'instance that internalizes zcap processing instead.');
}
assert.object(edvClient, 'edvClient');
this.edvClient = edvClient;
// setup EDV indexes...
// index to find by VC ID
edvClient.ensureIndex({attribute: 'content.id', unique: true});
// index to find by VC ID *or* auto-generated ID; covers case that
// VC does not have an `id`; this is backwards-compatible with
// `content.id` and requires that VCs without unique IDs have globally
// unambiguous IDs (e.g., UUIDs) generated for them to avoid conflicts
edvClient.ensureIndex({attribute: 'meta.id', unique: true});
// index to find by issuer
edvClient.ensureIndex({
attribute: ['meta.issuer', 'meta.displayable', 'content.type']
});
// index to find displayable VCs
edvClient.ensureIndex({attribute: 'meta.displayable'});
// index to find the VCs that *directly* bundled a credential; bundles
// may reference other VCs that are themselves bundles, but indirect
// bundle membership is not tracked here
edvClient.ensureIndex({attribute: 'meta.bundledBy'});
// index to find by type
edvClient.ensureIndex({attribute: ['content.type', 'meta.issuer']});
// cache for EDV credential docs, keyed by credential ID
this._credentialCache = new LruCache({
// 50 credentials at 10MiB each (max size) would be 500MiB -- much more
// likely to be smaller than that, e.g., ~5-100KiB each
max: 50,
// 24 hrs TTL (credentials rarely change; their meta might but calls
// should request that the cache not be used if necessary)
maxAge: 1000 * 60 * 60 * 24
});
this.addBundleContentsFirst = addBundleContentsFirst;
}
/**
* Gets a verifiable credential by its ID.
*
* @param {object} options - The options to use.
* @param {string} options.id - The ID of the credential.
* @param {boolean} [options.useCache=false] - True to allow loading from
* the cache; false to always load a fresh copy.
*
* @returns {Promise<object>} The EDV document for the stored VC.
*/
async get({id, useCache = false} = {}) {
if(useCache) {
return this._credentialCache.memoize({
key: id,
fn: () => this._getUncached({id})
});
}
const doc = await this._getUncached({id});
// update cache w/latest
this._credentialCache.cache.set(id, Promise.resolve(doc));
return doc;
}
/**
* Gets a bundle associated with a verifiable credential.
*
* @param {object} options - The options to use.
* @param {string} [options.id] - The ID of the credential.
* @param {object} [options.doc] - The decrypted EDV document for the
* credential.
*
* @returns {Promise<object>} The result: `{doc, bundle, allSubDocuments}`.
*/
async getBundle({id, doc} = {}) {
if(id && doc) {
throw new Error('Only one of "id" or "docId" may be given.');
}
if(!(id || doc)) {
throw new Error('Either "id" or "doc" must be given.');
}
if(id) {
doc = await this.get({id});
}
if(!(doc.content.id && doc.meta.bundle)) {
// no bundle associated with the doc
return {doc, bundle: false, allSubDocuments: []};
}
const bundleResult = await this._getBundle({id});
return {doc, ...bundleResult};
}
/**
* Gets all verifiable credential instances that match the given parameters.
*
* @param {object} options - The options to use.
* @param {object|Array} options.query - One or more query objects with
* `type`, `issuer`, `displayable`, and `bundledBy` filters.
* @param {object} [options.options] - Query options such as `limit`.
*
* @returns {Promise<object>} The matching EDV documents as an array in
* `documents`.
*/
async find({query, options = {}} = {}) {
assert.objectOrArrayOfObjects(query, 'query');
assert.object(options, 'options');
// normalize query to an array of queries
const queries = Array.isArray(query) ? query : [query];
// build `equals` EDV query
const equals = [];
for(const q of queries) {
assert.query(q, 'query');
const {type, issuer, displayable, bundledBy} = q;
const entry = {};
if(type) {
entry['content.type'] = type;
}
if(issuer) {
entry['meta.issuer'] = issuer;
}
if(displayable) {
entry['meta.displayable'] = displayable;
}
if(bundledBy) {
entry['meta.bundledBy'] = bundledBy;
}
equals.push(entry);
}
const q = {equals};
if(options.limit !== undefined) {
q.limit = options.limit;
}
if(options.count !== undefined) {
q.count = options.count;
}
const results = await this.edvClient.find(q);
// update credentials cache with results
const {documents} = results;
for(const doc of documents) {
if(doc?.content?.id) {
this._credentialCache.cache.set(doc.content.id, Promise.resolve(doc));
}
}
return results;
}
/**
* Converts a VPR query into a local query to run against `find()`.
*
* @param {object} options - The options to use.
* @param {object} options.vprQuery - A Verifiable Presentation Request query
* (e.g. `{type: "QueryByExample", ...}`).
*
* @returns {Promise<object>} An object with `queries` set to an array where
* each element is a separate query to be passed to `find()`; the
* queries will be ordered such that they match the VPR query order; future
* versions may add additional properties to the returned object such as
* trusted issuer VCs.
*/
async convertVPRQuery({vprQuery} = {}) {
assert.object(vprQuery, 'vprQuery');
const {type} = vprQuery;
if(type === 'QueryByExample') {
const {credentialQuery} = vprQuery;
return this._convertQueryByExample({credentialQuery});
}
throw new Error(`Unsupported query type: "${type}"`);
}
/**
* Stores a verifiable credential in EDV storage as the content an EDV
* document. If `bundleContents` is passed, the credential will be marked
* as a bundle and all sub-credentials will be upserted.
*
* @param {object} options - The options to use.
* @param {object} options.credential - The credential to insert; it will be
* set as the `content` of the EDV document.
* @param {object} [options.meta={}] - Custom meta data to set; the `issuer`
* field will be auto-populated if not set in the custom `meta`.
* @param {Array} [options.bundleContents=[]] - Optional bundle contents if
* the credential is a bundle of other credentials; each element is an
* object:
* `{credential, meta, [bundleContents], [dependent=true]}` where
* `bundleContents` is an optional set of sub-bundle contents and
* `dependent` specifies whether the sub-credential will be deleted when
* all of its parent bundles are deleted (however, if a sub-credential was
* already in storage, it will not be changed to `dependent`).
* @param {boolean} [options.addBundleContentsFirst] - Sets whether
* the credential or its bundle contents will be inserted first; see
* constructor for more details.
*
* @returns {Promise<object>} - The stored EDV document.
*/
async insert({
credential, meta = {}, bundleContents,
addBundleContentsFirst = this.addBundleContentsFirst
} = {}) {
assert.object(credential, 'credential');
assert.object(meta, 'meta');
const now = Date.now();
meta = {created: now, updated: now, ...meta};
// ensure `meta.id` is set
if(!meta.id) {
meta.id = credential.id ?? `urn:uuid:${uuid()}`;
}
if(bundleContents !== undefined) {
assert.bundleContents(bundleContents, 'bundleContents');
if(bundleContents.length > 0) {
// VC is a bundle
meta.bundle = true;
}
}
if(!meta.issuer) {
meta.issuer = _getIssuer({credential});
}
if(addBundleContentsFirst) {
// add any bundle contents first by request
if(bundleContents && bundleContents.length > 0) {
await this._addBundleContents({bundleId: meta.id, bundleContents});
}
}
// insert the credential
const result = await this.edvClient.insert({
doc: {meta, content: credential}
});
if(!addBundleContentsFirst) {
// now add any bundle contents
if(bundleContents && bundleContents.length > 0) {
await this._addBundleContents({bundleId: meta.id, bundleContents});
}
}
return result;
}
/**
* Upserts a verifiable credential in EDV storage, overwriting any previous
* version if one exists. If `bundleContents` is passed, the credential will
* be marked as a bundle and all sub-credentials will be upserted.
*
* @param {object} options - The options to use.
* @param {object} options.credential - The credential to upsert; it will be
* set as the `content` of the EDV document; either `credential.id` or
* `meta.id` must be a string (for credentials without an `id` property) to
* perform an update; if neither are set, then the credential will always
* be treated as new and inserted.
* @param {object} [options.meta={}] - Custom meta data to set; the `issuer`
* field will be auto-populated if not set in the custom `meta`.
* @param {Function} [options.mutator] - A function that takes the options
* `{doc, credential, meta}` and that is called if an existing credential
* is found and that must return the document to use to update the existing
* document; the default function will not overwrite an existing
* `credential` and will try to safely merge `meta` fields, this can be
* disabled by passing `false` to overwrite both `credential` and `meta`
* completely or a custom function can be passed.
* @param {Array} [options.bundleContents=[]] - Optional bundle contents if
* the credential is a bundle of other credentials; each element is an
* object:
* `{credential, meta, [bundleContents], [dependent=true]}` where
* `bundleContents` is an optional set of sub-bundle contents and
* `dependent` specifies whether the sub-credential will be deleted when
* all of its parent bundles are deleted (however, if a sub-credential was
* already in storage, it will not be changed to `dependent`).
* @param {boolean} [options.addBundleContentsFirst] - Sets whether
* the credential or its bundle contents will be upserted first; see
* constructor for more details.
*
* @returns {Promise<object>} - The stored EDV document.
*/
async upsert({
credential, meta = {}, mutator = defaultMutator, bundleContents,
addBundleContentsFirst = this.addBundleContentsFirst
} = {}) {
assert.object(credential, 'credential');
assert.object(meta, 'meta');
if(mutator !== undefined) {
// mutator may be false or a function
if(!(mutator === false || typeof mutator === 'function')) {
throw new TypeError('"mutator" must be false or a function.');
}
}
const now = Date.now();
meta = {created: now, updated: now, ...meta};
// ensure `meta.id` is set
if(!meta.id) {
meta.id = credential.id ?? `urn:uuid:${uuid()}`;
}
if(bundleContents !== undefined) {
assert.bundleContents(bundleContents, 'bundleContents');
if(bundleContents.length > 0) {
// VC is a bundle
meta.bundle = true;
}
}
if(!meta.issuer) {
meta.issuer = _getIssuer({credential});
}
if(addBundleContentsFirst) {
// add any bundle contents first by request
if(bundleContents && bundleContents.length > 0) {
await this._addBundleContents({bundleId: meta.id, bundleContents});
}
}
// upsert the credential...
/* Note: Here we need to guess whether the credential is new or not; this
determines whether we create a new EDV document or fetch and update an
existing one. A wrong guess will still work either way, but we'll it will
slow performance because we have to try again after adjusting our guess.
Since most credentials do not change, guessing that the credential is new
is usually the best guess. We also have a credential cache that we can
check. If the credential is in the cache (which we check without trying to
fetch it or update the cache), then we guess that the credential is not
new, otherwise we guess that it's new. */
// figure out our best guess -- and if it fails, loop to try again with
// a better guess; also loop if there are concurrent updates to try the
// update again
let doc;
let isNew = true;
if(this._credentialCache.cache.has(meta.id)) {
try {
doc = await this._credentialCache.cache.get(meta.id);
isNew = false;
} catch(e) {}
}
let result;
const retries = 10;
for(let i = 0; i < retries; ++i) {
if(isNew) {
// try to create a new doc
doc = {
id: await this.edvClient.generateId(),
content: credential, meta, sequence: 0
};
} else {
// try to modify an existing doc
if(mutator) {
doc = await mutator({doc, credential, meta});
} else {
// no custom mutator so just overwrite directly; preserve `created`
// date
const created = doc?.meta?.created ?? meta?.created;
doc.meta = {...meta, created};
doc.content = credential;
}
}
try {
// try to update the doc in EDV storage; break on success
result = await this.edvClient.update({doc});
break;
} catch(updateError) {
// if the error was NOT caused by a concurrent update
// (`InvalidStateError` which, btw, is only thrown when the doc isn't
// new) nor by a duplicate new doc, then we can't handle it; throw
if(!(updateError.name === 'InvalidStateError' ||
(updateError.name === 'DuplicateError' && isNew))) {
throw updateError;
}
// either a concurrent error occurred or the doc isn't actually new
// like we predicted...
try {
// try to get a fresh copy of the doc
doc = await this.get({id: meta.id});
// got it, so doc is not new; loop to try to update it
isNew = false;
continue;
} catch(e) {
// if some error other than `NotFoundError` occurred then we can't
// handle it gracefully; throw
if(e.name !== 'NotFoundError') {
throw e;
}
// doc doesn't exist and we were trying to insert it as new, which
// means some field other than `credential.id`/`meta.id` is in
// conflict and therefore we can't add it, so throw the original
// duplicate error
if(isNew) {
throw updateError;
}
// a concurrent error must have occurred a moment ago and now the doc
// doesn't exist; this is possible with EDV store errors or with EDV
// implementations that do not use tombstones like they should), so
// loop to try to be resilient and try to add it as new
isNew = true;
}
}
}
if(!result) {
// retries exhausted and no result
throw new Error(
`Failed to upsert credential "${meta.id}"; too many retries.`);
}
if(!addBundleContentsFirst) {
// now add any bundle contents
if(bundleContents && bundleContents.length > 0) {
await this._addBundleContents({bundleId: meta.id, bundleContents});
}
}
return result;
}
/**
* Removes a verifiable credential identified by its ID or EDV doc ID (for
* VCs that do not have IDs). If the credential is bundled by any other
* credential or if the credential is a bundle and `deleteBundle=false`,
* then an error will be thrown unless `force` is set to true.
*
* @param {object} options - The options to use.
* @param {string} [options.id] - The ID of the credential.
* @param {string} [options.docId] - The ID of the EDV document storing the
* credential.
* @param {boolean} [options.deleteBundle=true] - If `true` and credential is
* a bundle, then any credentials that are part of it and depend on its
* existence (`meta.dependent=true`) will also be deleted.
* @param {boolean} [options.force=false] - If `true` the credential will be
* forcibly deleted whether or not it is part of a bundle.
*
* @returns {Promise<object>} - An object with
* `{deleted: boolean, doc, bundle}` where `deleted` is set to true if
* anything was deleted; `doc` is only set if the deleted document was
* found and `bundle` is only set if a bundle was updated (an update will
* include unlinking the parent bundle from sub-bundles and, if any
* sub-credentials are dependent on parent bundles and no longer have any
* parents, they will be deleted as well).
*/
async delete({id, docId, deleteBundle = true, force = false} = {}) {
if(!(id || docId)) {
throw new TypeError('Either "id" or "docId" must be a string.');
}
if(id && docId) {
throw new Error('Only one of "id" or "docId" may be given.');
}
// loop to handle concurrent updates
while(true) {
try {
return await this._delete({id, docId, deleteBundle, force});
} catch(e) {
if(e.name !== 'InvalidStateError') {
throw e;
}
// loop to try again
}
}
}
// called from `insert` and `upsert` to add bundle contents
async _addBundleContents({bundleId, bundleContents}) {
// upsert all same-level bundle contents concurrently
const actions = bundleContents.map(entry => {
const {credential, meta = {}, bundleContents, dependent = true} = entry;
return () => {
const m = {...meta};
if(dependent) {
m.dependent = dependent;
}
// record that this VC is bundled by `bundleId`
const set = new Set(m.bundledBy || []);
set.add(bundleId);
m.bundledBy = [...set];
return this.upsert({credential, meta: m, bundleContents});
};
});
await pAll(actions, {concurrency: OPS_CONCURRENCY, stopOnError: true});
}
// called from `delete` as a helper within a concurrent ops handling loop
async _delete({id, docId, deleteBundle, force}) {
let doc;
let bundle;
try {
if(docId) {
// fetch doc by `docId`
try {
doc = await this.edvClient.get({id: docId});
id = doc.content.id;
} catch(e) {
if(e.name === 'NotFoundError') {
return {deleted: false, doc, bundle};
}
throw e;
}
}
// start fetching bundle
let bundleResult;
if(id) {
bundleResult = this._getBundle({id}).catch(e => e);
}
if(!doc) {
// fetch doc by `content.id` or `meta.id`
({documents: [doc]} = await this.edvClient.find({
equals: [{'content.id': id}, {'meta.id': id}]
}));
}
// wait for bundle to be loaded
bundleResult = await bundleResult;
if(bundleResult instanceof Error) {
throw bundleResult;
}
// if another VC bundles the VC to be deleted, then throw
if(!force && doc && doc.meta.bundledBy &&
doc.meta.bundledBy.length > 0) {
const error = new Error(
'Cannot delete credential; all other credentials that bundle it ' +
'must be deleted first.');
error.name = 'ConstraintError';
throw error;
}
if(bundleResult.allSubDocuments.length > 0) {
if(deleteBundle) {
// delete / unlink the bundled docs first so if this operation
// fails, a delete can be attempted again later
({bundle} = await this._deleteBundledDocs(bundleResult));
} else if(!force) {
const error = new Error(
'Cannot delete credential; other credentials are bundled by it.');
error.name = 'ConstraintError';
throw error;
}
}
if(!doc) {
// no doc found
return {deleted: !!bundle, doc, bundle};
}
await this.edvClient.delete({doc});
return {deleted: true, doc, bundle};
} catch(e) {
if(e.name === 'NotFoundError') {
return {deleted: !!bundle, doc, bundle};
}
throw e;
}
}
// get all docs involved in a bundle
async _getBundle({id} = {}) {
// first, recursively load docs in bundles
const bundle = {id, contents: []};
const retrievedDocs = new Map();
const bundles = new Map([[id, bundle]]);
let bundleIds = [id];
while(bundleIds.length > 0) {
/* Note: Since this is an asynchronous and partitioned system, it is
possible for two different EDV documents to be returned that have the
same `content.id`. This does not mean that both documents had the same
value in the database at the *same time* -- as uniqueness contraints
prevents this. Rather, multiple queries for `content.id === X` run at
different times may produce different document results if the documents
containing those values are changing *concurrently*.
For example, an operation to delete a credential bundle that contains `X`
could be run concurrently with another operation to insert a bundle that
contains `X` and yet another operation to delete a bundle containing `X`,
etc. Under these conditions, the database may reach a less than ideal
state, but it should be recoverable by finding and deleting the top-level
bundle(s) connecting VCs together and finding and deleting any orphaned,
dependent VCs.
Code to do this automatically is not provided in the current version of
this module. */
// load all bundled docs with a single query
const query = bundleIds.map(id => ({bundledBy: id}));
const {documents: docs} = await this.find({query});
bundleIds = [];
for(const doc of docs) {
// skip root bundle VC and any already retrieved docs
if(doc.content.id === id || retrievedDocs.has(doc.id)) {
continue;
}
// mark doc as retrieved
retrievedDocs.set(doc.id, doc);
if(doc?.content?.id) {
// update credential cache
this._credentialCache.cache.set(doc.content.id, Promise.resolve(doc));
}
// if the document is not itself a bundle, continue
if(!(doc.content.id && doc.meta.bundle)) {
continue;
}
// if a sub-bundle has already been created, continue
if(bundles.has(doc.content.id)) {
continue;
}
// add empty bundle and add bundle ID to fetch its contents
bundles.set(doc.content.id, {id: doc.content.id, contents: []});
bundleIds.push(doc.content.id);
}
}
// second, now that all docs have been fetched, populate all bundles with
// document references
const allSubDocuments = [...retrievedDocs.values()];
for(const doc of allSubDocuments) {
// build reference to this document and any sub-bundle
const ref = {doc};
if(doc.content.id && doc.meta.bundle) {
ref.bundle = bundles.get(doc.content.id);
}
// for every bundle in which this doc appears, add the reference
const {meta: {bundledBy = []}} = doc;
for(const bundleId of bundledBy) {
const bundle = bundles.get(bundleId);
// it is possible that the bundling VC is not part of the current
// bundle (or erroneously does not exist in the database)
if(bundle) {
bundle.contents.push(ref);
}
}
}
return {bundle, allSubDocuments};
}
// delete or unlink bundled docs from the given bundle
async _deleteBundledDocs({bundle} = {}) {
// recursively iterate through bundle contents, stopping recursion at any
// independent VCs
let next = [bundle];
const operationMap = new Map();
const processedBundles = new Set();
const bundleOps = new Set();
const nonBundleOps = new Set();
while(next.length > 0) {
const current = next;
next = [];
for(const {id: bundleId, contents} of current) {
processedBundles.add(bundleId);
for(const {doc, bundle: subBundle} of contents) {
// unlink `bundleId` from doc
const set = new Set(doc.meta.bundledBy);
set.delete(bundleId);
if(set.size > 0) {
// other VCs bundle this doc, so do not recurse into any
// sub-bundles
doc.meta.bundledBy = [...set];
} else {
// doc no longer bundled by any other VCs
delete doc.meta.bundledBy;
}
// create op to update doc
let op = operationMap.get(doc);
if(!op) {
operationMap.set(doc, op = {
doc, type: 'update', before: new Set([bundleId])
});
// filter op into bundle / non-bundle ops set
if(doc.meta.bundle) {
bundleOps.add(op);
} else {
nonBundleOps.add(op);
}
} else {
op.before.add(bundleId);
}
// if doc is either bundled by another VC or is not a dependent,
// do not mark for deletion and do not recurse; note: an independent
// VC is not to be deleted when all VCs that bundled it are deleted
if(set.size > 0 || !doc.meta.dependent) {
continue;
}
// mark doc as to be deleted instead of updated
op.type = 'delete';
// VC is dependent; if it has been processed yet and has a
// sub-bundle, recurse
if(subBundle && !processedBundles.has(doc.content.id)) {
next.push(subBundle);
}
}
}
}
// process all non-bundle ops in parallel
try {
await this._runOps({ops: [...nonBundleOps]});
} catch(e) {
if(e.name !== 'AggregateError') {
throw e;
}
// if all of the errors are `InvalidStateError`, throw the first one
if(e.errors?.every(({name}) => name === 'InvalidStateError')) {
throw e.errors[0];
}
// some errors were not invalid state, so throw the aggregate error
// to stop the deletion process
throw e;
}
/* Note: This could be further optimized by sorting bundle VCs into groups
that can be deleted in parallel. A new version could add this optimization
in the future. */
// determine bundle deletion order
const sorted = [...bundleOps].sort((a, b) => {
// if `a` is before `b`, it comes first and vice versa
if(a.before.has(b.doc.content.id)) {
return -1;
}
if(b.before.has(a.doc.content.id)) {
return 1;
}
return 0;
});
// run bundle ops in serial
await this._runOps({ops: sorted, concurrency: 1, stopOnError: true});
return {bundle};
}
async _convertQueryByExample({credentialQuery} = {}) {
assert.objectOrArrayOfObjects(credentialQuery, 'credentialQuery');
// normalize query to be an array
const query = Array.isArray(credentialQuery) ?
credentialQuery : [credentialQuery];
// build local queries
const queries = [];
for(const q of query) {
const {example, trustedIssuer = []} = q;
assert.object(example, 'credentialQuery.example');
const {type} = example;
if(!(Array.isArray(type) || typeof type === 'string')) {
throw new Error(
'"credentialQuery.example" without "type" is not supported.');
}
// normalize to arrays
const trustedIssuers = Array.isArray(trustedIssuer) ?
trustedIssuer : [trustedIssuer];
const types = Array.isArray(type) ? type : [type];
// build query to find all VCs that match any combination of type+issuer
const query = [];
// get issuer IDs
const issuers = trustedIssuers.map(({id}) => {
if(id) {
return id;
}
// future version could do internal queries to find trusted issuer IDs
// and return additional information along with `queries` below
const error = new Error(
'"credentialQuery.trustedIssuer" without an "id" is not supported.');
error.name = 'NotSupportedError';
throw error;
});
for(const type of types) {
if(issuers.length === 0) {
query.push({type});
continue;
}
for(const issuer of issuers) {
query.push({type, issuer});
}
}
queries.push(query);
}
return {queries};
}
async _getUncached({id}) {
const {documents: [doc]} = await this.edvClient.find({
equals: [{'content.id': id}, {'meta.id': id}]
});
if(!doc) {
const err = new Error('Verifiable Credential not found.');
err.name = 'NotFoundError';
throw err;
}
return doc;
}
async _runOps({ops, concurrency = OPS_CONCURRENCY, stopOnError = false}) {
// try to run all operations to completion
const actions = [];
for(const op of ops) {
const {doc} = op;
if(op.type === 'update') {
actions.push(() => this.edvClient.update({doc}));
} else if(op.type === 'delete') {
actions.push(() => this.edvClient.delete({doc}));
} else {
throw new Error(`Invalid operation type "${op.type}".`);
}
}
await pAll(actions, {concurrency, stopOnError});
}
}
// called when bundle contents are being upserted; must ensure that the bundle
// IDs from `meta.bundledBy` are added
export function defaultMutator({doc, credential, meta}) {
// if credential is different, log a duplicate error, but do not overwrite;
// preserve the original content
// note: if a VC was concurrently deleted, this code path should not be hit
// because it would trigger a conflict error and a retry -- which would not
// find the same doc again by `content.id` if the VC was still deleted during
// the retry
if(canonicalize(doc.content) !== canonicalize(credential)) {
const credentialId = credential.id ?? meta.id;
console.error(
`Credential ID "${credentialId}" is a duplicate and the previously ` +
'stored credential does not match.', {
old: doc.content,
new: credential
});
/*const error = new Error('Duplicate credential.');
error.name = 'DuplicateError';
error.credentialId = credentialId;
throw error;*/
}
// only update `doc.meta`, not `doc.content`
const newMeta = {...doc.meta};
for(const key in meta) {
// skip certain meta keys for special handling
if(key === 'bundledBy' || key === 'dependent' || key === 'created') {
continue;
}
newMeta[key] = meta[key];
}
// only overwrite `created` if not present
if(newMeta.created === undefined && meta.created !== undefined) {
newMeta.created = meta.created;
}
// union `bundledBy`
if(meta.bundledBy) {
newMeta.bundledBy = _union(doc.meta.bundledBy, meta.bundledBy);
}
// only use the new `dependent` value if it is explicitly set to false; we
// leave an existing value alone in the `true` case to preserve previously
// set independence
if(meta.dependent === false) {
delete newMeta.dependent;
}
doc.meta = newMeta;
return doc;
}
function _getIssuer({credential}) {
const {issuer} = credential;
if(!issuer) {
throw new Error('"credential.issuer" must be an object or string.');
}
if(!(typeof issuer === 'string' || typeof issuer.id === 'string')) {
throw new Error(
'"credential.issuer" must be a URI or an object containing an ' +
'"id" property.');
}
return issuer.id || issuer;
}
function _union(a1, a2) {
if(!a1 || !a2) {
return a1 || a2;
}
const s = new Set(a1);
a2.forEach(s.add, s);
return [...s];
}