diff --git a/src/credential-status-manager-base.ts b/src/credential-status-manager-base.ts index 5b5b4ad..3e03d6b 100644 --- a/src/credential-status-manager-base.ts +++ b/src/credential-status-manager-base.ts @@ -5,7 +5,14 @@ import { CONTEXT_URL_V1 } from '@digitalbazaar/vc-status-list-context'; import { VerifiableCredential } from '@digitalcredentials/vc-data-model'; import { createCredential, createList, decodeList } from '@digitalcredentials/vc-status-list'; import { v4 as uuid } from 'uuid'; -import { BadRequestError, CustomError, InternalServerError, NotFoundError } from './errors.js'; +import { + BadRequestError, + CustomError, + InternalServerError, + InvalidDatabaseStateError, + NotFoundError, + WriteConflictError +} from './errors.js'; import { DidMethod, deriveStatusCredentialId, @@ -48,6 +55,7 @@ interface EventRecord { // Type definition for credential status config export interface ConfigRecord { id: string; + statusCredentialSiteOrigin: string; latestStatusCredentialId: string; latestCredentialsIssuedCounter: number; allCredentialsIssuedCounter: number; @@ -92,6 +100,12 @@ interface UpdateStatusOptions { credentialStatus: CredentialState; } +// Type definition for getDatabaseState method output +interface GetDatabaseStateResult { + valid: boolean; + error?: InvalidDatabaseStateError; +} + // Type definition for database connection options export interface DatabaseConnectionOptions { databaseUrl?: string; @@ -191,7 +205,7 @@ export abstract class BaseCredentialStatusManager { const missingOptions = [] as Array; - const isProperlyConfigured = BASE_MANAGER_REQUIRED_OPTIONS.every( + const hasValidConfiguration = BASE_MANAGER_REQUIRED_OPTIONS.every( (option: keyof BaseCredentialStatusManagerOptions) => { if (!options[option]) { missingOptions.push(option as any); @@ -200,7 +214,7 @@ export abstract class BaseCredentialStatusManager { } ); - if (!isProperlyConfigured) { + if (!hasValidConfiguration) { throw new BadRequestError({ message: 'You have neglected to set the following required options ' + @@ -242,14 +256,15 @@ export abstract class BaseCredentialStatusManager { credential.id = uuid(); } - // ensure that credential contains the proper status credential context + // ensure that credential contains valid status credential context if (!credential['@context'].includes(CONTEXT_URL_V1)) { credential['@context'].push(CONTEXT_URL_V1); } - // retrieve config data + // retrieve config let { id, + statusCredentialSiteOrigin, latestStatusCredentialId, latestCredentialsIssuedCounter, allCredentialsIssuedCounter @@ -260,7 +275,7 @@ export abstract class BaseCredentialStatusManager { // do not allocate new entry if ID is already being tracked if (event) { - // retrieve relevant event data + // retrieve relevant event const { statusCredentialId, credentialStatusIndex } = event; // attach credential status @@ -281,6 +296,7 @@ export abstract class BaseCredentialStatusManager { credentialStatus }, newStatusCredential: false, + statusCredentialSiteOrigin, latestStatusCredentialId, latestCredentialsIssuedCounter, allCredentialsIssuedCounter @@ -293,8 +309,8 @@ export abstract class BaseCredentialStatusManager { newStatusCredential = true; latestCredentialsIssuedCounter = 0; latestStatusCredentialId = generateStatusCredentialId(); - allCredentialsIssuedCounter++; } + allCredentialsIssuedCounter++; latestCredentialsIssuedCounter++; // attach credential status @@ -316,6 +332,7 @@ export abstract class BaseCredentialStatusManager { credentialStatus }, newStatusCredential, + statusCredentialSiteOrigin, latestStatusCredentialId, latestCredentialsIssuedCounter, allCredentialsIssuedCounter @@ -376,7 +393,7 @@ export abstract class BaseCredentialStatusManager { }); } - // create and persist status data + // create and persist status credential await this.createStatusCredential({ id: latestStatusCredentialId, credential: statusCredential @@ -421,7 +438,7 @@ export abstract class BaseCredentialStatusManager { eventId }, options); - // persist updates to config data + // persist updates to config await this.updateConfig({ latestStatusCredentialId, ...embedCredentialStatusResultRest @@ -447,7 +464,7 @@ export abstract class BaseCredentialStatusManager { }); } - // retrieve relevant event data + // retrieve relevant event const { credentialSubject, statusCredentialId, @@ -619,6 +636,7 @@ export abstract class BaseCredentialStatusManager { const statusCredentialId = generateStatusCredentialId(); const config: ConfigRecord = { id: uuid(), + statusCredentialSiteOrigin: this.statusCredentialSiteOrigin, latestStatusCredentialId: statusCredentialId, latestCredentialsIssuedCounter: 0, allCredentialsIssuedCounter: 0 @@ -642,7 +660,7 @@ export abstract class BaseCredentialStatusManager { }); } - // create and persist status data + // create and persist status credential await this.createStatusCredential({ id: statusCredentialId, credential: statusCredential @@ -716,56 +734,109 @@ export abstract class BaseCredentialStatusManager { return true; } - // checks if database tables are properly configured - async databaseTablesProperlyConfigured(options?: DatabaseConnectionOptions): Promise { + // retrieves database state + async getDatabaseState(options?: DatabaseConnectionOptions): Promise { try { - // retrieve config data + // retrieve config const { + statusCredentialSiteOrigin, latestStatusCredentialId, latestCredentialsIssuedCounter } = await this.getConfigRecord(options); + + // ensure that the status credential site origins match + if (this.statusCredentialSiteOrigin !== statusCredentialSiteOrigin) { + return { + valid: false, + error: new InvalidDatabaseStateError({ + message: 'There is a mismatch between the site origin ' + + 'that you instantiated this credential status manager with ' + + `(${statusCredentialSiteOrigin}) ` + + 'and the site origin that you are trying to use now ' + + `(${this.statusCredentialSiteOrigin}).` + }) + }; + } + const statusCredentialUrl = `${this.statusCredentialSiteOrigin}/${latestStatusCredentialId}`; const statusCredentials = await this.getAllStatusCredentials(options); - // ensure status data is consistent + // ensure that status is consistent let hasLatestStatusCredentialId = false; - for (const credential of statusCredentials) { - // report error for compact JWT credentials - if (typeof credential === 'string') { - return false; + const invalidStatusCredentialIds = []; + for (const statusCredential of statusCredentials) { + // ensure that status credential has valid type + if (typeof statusCredential === 'string') { + return { + valid: false, + error: new InvalidDatabaseStateError({ + message: 'This library does not support compact JWT ' + + `status credentials: ${statusCredential}` + }) + }; } - // ensure status credential is well formed - hasLatestStatusCredentialId = hasLatestStatusCredentialId || (credential.id?.endsWith(latestStatusCredentialId) ?? false); - const hasProperStatusCredentialType = credential.type.includes('StatusList2021Credential'); - const hasProperStatusCredentialSubId = credential.credentialSubject.id?.startsWith(statusCredentialUrl) ?? false; - const hasProperStatusCredentialSubType = credential.credentialSubject.type === 'StatusList2021'; - const hasProperStatusCredentialSubStatusPurpose = credential.credentialSubject.statusPurpose === 'revocation'; - const hasProperStatusFormat = hasProperStatusCredentialType && - hasProperStatusCredentialSubId && - hasProperStatusCredentialSubType && - hasProperStatusCredentialSubStatusPurpose; - if (!hasProperStatusFormat) { - return false; + // ensure that status credential is well formed + hasLatestStatusCredentialId = hasLatestStatusCredentialId || (statusCredential.id?.endsWith(latestStatusCredentialId) ?? false); + const hasValidStatusCredentialType = statusCredential.type.includes('StatusList2021Credential'); + const hasValidStatusCredentialSubId = statusCredential.credentialSubject.id?.startsWith(statusCredentialUrl) ?? false; + const hasValidStatusCredentialSubType = statusCredential.credentialSubject.type === 'StatusList2021'; + const hasValidStatusCredentialSubStatusPurpose = statusCredential.credentialSubject.statusPurpose === 'revocation'; + const hasValidStatusCredentialFormat = hasValidStatusCredentialType && + hasValidStatusCredentialSubId && + hasValidStatusCredentialSubType && + hasValidStatusCredentialSubStatusPurpose; + if (!hasValidStatusCredentialFormat) { + invalidStatusCredentialIds.push(statusCredential.id); } } + if (invalidStatusCredentialIds.length !== 0) { + return { + valid: false, + error: new InvalidDatabaseStateError({ + message: 'Status credentials with the following IDs ' + + 'have an invalid format: ' + + `${invalidStatusCredentialIds.map(id => `"${id as string}"`).join(', ')}` + }) + }; + } // ensure that latest status credential is being tracked in the config if (!hasLatestStatusCredentialId) { - return false; + return { + valid: false, + error: new InvalidDatabaseStateError({ + message: `Latest status credential ("${latestStatusCredentialId}") ` + + 'is not being tracked in config.' + }) + }; } // retrieve credential IDs from event log const credentialIds = await this.getAllCredentialIds(options); - const hasProperEvents = credentialIds.length === - (statusCredentials.length - 1) * - CREDENTIAL_STATUS_LIST_SIZE + - latestCredentialsIssuedCounter; + const credentialIdsCounter = credentialIds.length; + const credentialsIssuedCounter = (statusCredentials.length - 1) * + CREDENTIAL_STATUS_LIST_SIZE + + latestCredentialsIssuedCounter; + const hasValidEvents = credentialIdsCounter === credentialsIssuedCounter; + + if (!hasValidEvents) { + return { + valid: false, + error: new InvalidDatabaseStateError({ + message: 'There is a mismatch between the credentials tracked ' + + 'in the config and the credentials tracked in the event log.' + }) + }; + } // ensure that all checks pass - return hasProperEvents; - } catch (error) { - return false; + return { valid: true }; + } catch (error: any) { + return { + valid: false, + error: new InvalidDatabaseStateError({ message: error.message }) + }; } } @@ -825,6 +896,9 @@ export abstract class BaseCredentialStatusManager { const { id } = statusCredentialRecord; await this.updateRecord(this.statusCredentialTableName, 'id', id, statusCredentialRecord, options); } catch (error: any) { + if (error instanceof WriteConflictError) { + throw error; + } throw new InternalServerError({ message: `Unable to update status credential: ${error.message}` }); @@ -837,6 +911,9 @@ export abstract class BaseCredentialStatusManager { const { id } = config; await this.updateRecord(this.configTableName, 'id', id, config, options); } catch (error: any) { + if (error instanceof WriteConflictError) { + throw error; + } throw new InternalServerError({ message: `Unable to update config: ${error.message}` }); @@ -849,6 +926,9 @@ export abstract class BaseCredentialStatusManager { const { credentialId } = credentialEvent; await this.updateRecord(this.credentialEventTableName, 'credentialId', credentialId, credentialEvent, options); } catch (error: any) { + if (error instanceof WriteConflictError) { + throw error; + } throw new InternalServerError({ message: `Unable to update event for credential: ${error.message}` }); @@ -909,8 +989,14 @@ export abstract class BaseCredentialStatusManager { } // retrieves status credential by ID - async getStatusCredential(statusCredentialId: string, options?: DatabaseConnectionOptions): Promise { - const { credential } = await this.getStatusCredentialRecordById(statusCredentialId, options); + async getStatusCredential(statusCredentialId?: string, options?: DatabaseConnectionOptions): Promise { + let statusCredentialFinal; + if (statusCredentialId) { + statusCredentialFinal = statusCredentialId; + } else { + ({ latestStatusCredentialId: statusCredentialFinal } = await this.getConfigRecord(options)); + } + const { credential } = await this.getStatusCredentialRecordById(statusCredentialFinal, options); return credential as VerifiableCredential; } diff --git a/src/credential-status-manager-index.ts b/src/credential-status-manager-index.ts index a3b5c91..a28f540 100644 --- a/src/credential-status-manager-index.ts +++ b/src/credential-status-manager-index.ts @@ -14,7 +14,7 @@ import { BadRequestError, InternalServerError, InvalidCredentialsError, - InvalidStateError, + InvalidDatabaseStateError, MissingDatabaseError, MissingDatabaseTableError } from './errors.js'; @@ -105,10 +105,10 @@ export async function createStatusManager(options: CredentialStatusManagerOption await statusManager.initializeDatabaseResources(options); } - // database should be properly configured by this point - const tablesProperlyConfigured = await statusManager.databaseTablesProperlyConfigured(options); - if (!tablesProperlyConfigured) { - throw new InvalidStateError({ statusManager }); + // database should have valid configuration by this point + const databaseState = await statusManager.getDatabaseState(options); + if (!databaseState.valid) { + throw databaseState.error as InvalidDatabaseStateError; } } else { // database should already exist if autoDeployDatabase is not configured @@ -124,11 +124,11 @@ export async function createStatusManager(options: CredentialStatusManagerOption const tablesEmpty = await statusManager.databaseTablesEmpty(options); if (!tablesEmpty) { - // database tables should be properly configured if + // database tables should have valid configuration if // they are not empty and autoDeployDatabase is not configured - const tablesProperlyConfigured = await statusManager.databaseTablesProperlyConfigured(options); - if (!tablesProperlyConfigured) { - throw new InvalidStateError({ statusManager }); + const databaseState = await statusManager.getDatabaseState(options); + if (!databaseState.valid) { + throw databaseState.error as InvalidDatabaseStateError; } } else { // database tables should be initialized if diff --git a/src/credential-status-manager-mongodb.ts b/src/credential-status-manager-mongodb.ts index 5f273e7..d532015 100644 --- a/src/credential-status-manager-mongodb.ts +++ b/src/credential-status-manager-mongodb.ts @@ -14,7 +14,7 @@ import { DatabaseConnectionOptions, DatabaseService } from './credential-status-manager-base.js'; -import { BadRequestError } from './errors.js'; +import { BadRequestError, CustomError, WriteConflictError } from './errors.js'; // Type definition for MongoDB connection interface MongoDbConnection { @@ -130,25 +130,29 @@ export class MongoDbCredentialStatusManager extends BaseCredentialStatusManager // executes function as transaction async executeTransaction(func: (options?: MongoDbConnectionOptions) => Promise): Promise { - let success = false; - while (!success) { + let done = false; + while (!done) { const { client } = await this.connectDatabase(); const session = client.startSession(); const transactionOptions: TransactionOptions = { readPreference: ReadPreference.primary, - readConcern: { level: ReadConcernLevel.majority }, + readConcern: { level: ReadConcernLevel.local }, writeConcern: { w: 'majority' }, maxTimeMS: 30000 }; try { const finalResult = await session.withTransaction(async () => { const funcResult = await func({ client, session }); - success = true; + done = true; return funcResult; }, transactionOptions); - success = true; + done = true; return finalResult; } catch (error) { + if (!(error instanceof WriteConflictError)) { + done = true; + throw error; + } const delay = Math.floor(Math.random() * 1000); await new Promise(resolve => setTimeout(resolve, delay)); } finally { @@ -284,6 +288,12 @@ export class MongoDbCredentialStatusManager extends BaseCredentialStatusManager const table = database.collection(tableName); const query = { [recordIdKey]: recordIdValue }; await table.findOneAndReplace(query, record as Document, { session }); + } catch (error: any) { + if (error.codeName === 'WriteConflict') { + throw new WriteConflictError({ + message: error.message + }); + } } finally { if (!session) { // otherwise handled in executeTransaction diff --git a/src/errors.ts b/src/errors.ts index 02bcb27..6fc372a 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -47,10 +47,18 @@ export class InternalServerError extends CustomError { } } -export class InvalidStateError extends CustomError { +export class WriteConflictError extends CustomError { constructor(options?: CustomErrorOptionalOptions) { const { message } = options ?? {}; - const defaultMessage = 'The internal data has reached an invalid state.'; + const defaultMessage = 'This operation conflicted with another operation.'; + super({ message, defaultMessage, code: 409 }); + } +} + +export class InvalidDatabaseStateError extends CustomError { + constructor(options?: CustomErrorOptionalOptions) { + const { message } = options ?? {}; + const defaultMessage = 'The status database has an invalid state.'; super({ message, defaultMessage, code: 500 }); } }