@synet/credential
Version:
VC Credentials - Simple, Robust, Unit-based Verifiable Credentials service
452 lines (380 loc) • 11.9 kB
text/typescript
/**
* Simple JSON-based storage for verifiable credentials
*
* This module provides a basic file-based storage implementation for
* verifiable credentials using JSON files. It's designed to be simple,
* dependency-free, and suitable for development and testing.
*
* Features:
* - JSON file-based storage
* - Type-safe credential handling
* - Search and filtering capabilities
* - Thread-safe operations
* - Automatic backup and recovery
*
* @author Synet Team
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { W3CVerifiableCredential, BaseCredentialSubject, CredentialStorage } from '../src/types-base';
/**
* JSON file-based credential storage
*/
export class JSONCredentialStorage<T extends W3CVerifiableCredential = W3CVerifiableCredential>
implements CredentialStorage<T> {
private readonly storageFile: string;
private readonly backupFile: string;
private credentials: Map<string, T> = new Map();
private lastSaveTime= 0;
private saveTimeout: NodeJS.Timeout | null = null;
constructor(
storageFile = './credentials.json',
private readonly autosave: boolean = true,
private readonly autosaveDelay: number = 1000,
) {
this.storageFile = path.resolve(storageFile);
this.backupFile = `${this.storageFile}.backup`;
this.loadFromFile();
}
/**
* Load credentials from JSON file
*/
private loadFromFile(): void {
try {
if (fs.existsSync(this.storageFile)) {
const data = fs.readFileSync(this.storageFile, 'utf8');
const credentialData = JSON.parse(data);
// Convert object to Map
this.credentials = new Map();
for (const [id, credential] of Object.entries(credentialData)) {
this.credentials.set(id, credential as T);
}
} else {
this.credentials = new Map();
}
} catch (error) {
console.warn(`Failed to load credentials from ${this.storageFile}:`, error);
// Try to load from backup
if (fs.existsSync(this.backupFile)) {
try {
const backupData = fs.readFileSync(this.backupFile, 'utf8');
const credentialData = JSON.parse(backupData);
this.credentials = new Map();
for (const [id, credential] of Object.entries(credentialData)) {
this.credentials.set(id, credential as T);
}
console.log(`Loaded credentials from backup file: ${this.backupFile}`);
} catch (backupError) {
console.error('Failed to load backup file:', backupError);
this.credentials = new Map();
}
} else {
this.credentials = new Map();
}
}
}
/**
* Save credentials to JSON file
*/
private saveToFile(): void {
try {
// Create backup before saving
if (fs.existsSync(this.storageFile)) {
fs.copyFileSync(this.storageFile, this.backupFile);
}
// Convert Map to object
const credentialData: Record<string, T> = {};
for (const [id, credential] of this.credentials.entries()) {
credentialData[id] = credential;
}
// Ensure directory exists
const dir = path.dirname(this.storageFile);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Write to file
fs.writeFileSync(this.storageFile, JSON.stringify(credentialData, null, 2), 'utf8');
this.lastSaveTime = Date.now();
} catch (error) {
console.error(`Failed to save credentials to ${this.storageFile}:`, error);
throw error;
}
}
/**
* Schedule autosave if enabled
*/
private scheduleAutosave(): void {
if (!this.autosave) return;
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
this.saveTimeout = setTimeout(() => {
this.saveToFile();
this.saveTimeout = null;
}, this.autosaveDelay);
}
/**
* Save a credential
*/
async save(credential: T): Promise<void> {
if (!credential.id) {
throw new Error('Credential must have an ID');
}
this.credentials.set(credential.id, credential);
this.scheduleAutosave();
}
/**
* Load a credential by ID
*/
async load(id: string): Promise<T | null> {
return this.credentials.get(id) || null;
}
/**
* Delete a credential by ID
*/
async delete(id: string): Promise<void> {
const deleted = this.credentials.delete(id);
if (deleted) {
this.scheduleAutosave();
}
}
/**
* List all credentials
*/
async list(): Promise<T[]> {
return Array.from(this.credentials.values());
}
/**
* Search credentials by criteria
*/
async search(query: Record<string, unknown>): Promise<T[]> {
const results: T[] = [];
for (const credential of this.credentials.values()) {
let matches = true;
for (const [key, value] of Object.entries(query)) {
if (!this.matchesQuery(credential, key, value)) {
matches = false;
break;
}
}
if (matches) {
results.push(credential);
}
}
return results;
}
/**
* Check if a credential matches a query
*/
private matchesQuery(credential: T, key: string, value: unknown): boolean {
try {
const credentialValue = this.getNestedValue(credential, key);
if (credentialValue === undefined) {
return false;
}
// Handle different types of matching
if (typeof value === 'string' && typeof credentialValue === 'string') {
return credentialValue.toLowerCase().includes(value.toLowerCase());
}
if (Array.isArray(value) && Array.isArray(credentialValue)) {
return value.some(v => credentialValue.includes(v));
}
if (Array.isArray(credentialValue) && !Array.isArray(value)) {
return credentialValue.includes(value);
}
return credentialValue === value;
} catch (error) {
console.warn(`Error matching query ${key}:`, error);
return false;
}
}
/**
* Get nested value from object using dot notation
*/
private getNestedValue(obj: any, path: string): unknown {
return path.split('.').reduce((current, key) => {
return current && typeof current === 'object' ? current[key] : undefined;
}, obj);
}
/**
* Get storage statistics
*/
async getStats(): Promise<{
totalCredentials: number;
fileSize: number;
lastSaved: number;
types: Record<string, number>;
issuers: Record<string, number>;
}> {
const stats = {
totalCredentials: this.credentials.size,
fileSize: 0,
lastSaved: this.lastSaveTime,
types: {} as Record<string, number>,
issuers: {} as Record<string, number>,
};
try {
if (fs.existsSync(this.storageFile)) {
const fileStats = fs.statSync(this.storageFile);
stats.fileSize = fileStats.size;
}
} catch (error) {
console.warn('Failed to get file stats:', error);
}
// Analyze credential types and issuers
for (const credential of this.credentials.values()) {
// Count types
if (credential.type && Array.isArray(credential.type)) {
for (const type of credential.type) {
if (type !== 'VerifiableCredential') {
stats.types[type] = (stats.types[type] || 0) + 1;
}
}
}
// Count issuers
if (credential.issuer?.id) {
stats.issuers[credential.issuer.id] = (stats.issuers[credential.issuer.id] || 0) + 1;
}
}
return stats;
}
/**
* Manually save all credentials to file
*/
async flush(): Promise<void> {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
}
this.saveToFile();
}
/**
* Clear all credentials
*/
async clear(): Promise<void> {
this.credentials.clear();
this.scheduleAutosave();
}
/**
* Check if a credential exists
*/
async exists(id: string): Promise<boolean> {
return this.credentials.has(id);
}
/**
* Get credential count
*/
async count(): Promise<number> {
return this.credentials.size;
}
/**
* Import credentials from another storage file
*/
async import(sourceFile: string, overwrite: boolean | false): Promise<number> {
try {
if (!fs.existsSync(sourceFile)) {
throw new Error(`Source file does not exist: ${sourceFile}`);
}
const data = fs.readFileSync(sourceFile, 'utf8');
const credentialData = JSON.parse(data);
let importedCount = 0;
for (const [id, credential] of Object.entries(credentialData)) {
if (overwrite || !this.credentials.has(id)) {
this.credentials.set(id, credential as T);
importedCount++;
}
}
if (importedCount > 0) {
this.scheduleAutosave();
}
return importedCount;
} catch (error) {
throw new Error(`Failed to import credentials: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Export credentials to another file
*/
async export(targetFile: string, filter?: (credential: T) => boolean): Promise<number> {
try {
let credentialsToExport = Array.from(this.credentials.values());
if (filter) {
credentialsToExport = credentialsToExport.filter(filter);
}
const exportData: Record<string, T> = {};
for (const credential of credentialsToExport) {
exportData[credential.id] = credential;
}
const targetDir = path.dirname(targetFile);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
fs.writeFileSync(targetFile, JSON.stringify(exportData, null, 2), 'utf8');
return credentialsToExport.length;
} catch (error) {
throw new Error(`Failed to export credentials: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Cleanup method - call this when shutting down
*/
async cleanup(): Promise<void> {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
}
// Final save
this.saveToFile();
}
}
/**
* In-memory credential storage (for testing)
*/
export class MemoryCredentialStorage<T extends W3CVerifiableCredential = W3CVerifiableCredential>
implements CredentialStorage<T> {
private credentials: Map<string, T> = new Map();
async save(credential: T): Promise<void> {
if (!credential.id) {
throw new Error('Credential must have an ID');
}
this.credentials.set(credential.id, credential);
}
async load(id: string): Promise<T | null> {
return this.credentials.get(id) || null;
}
async delete(id: string): Promise<void> {
this.credentials.delete(id);
}
async list(): Promise<T[]> {
return Array.from(this.credentials.values());
}
async search(query: Record<string, unknown>): Promise<T[]> {
const results: T[] = [];
for (const credential of this.credentials.values()) {
let matches = true;
for (const [key, value] of Object.entries(query)) {
const credentialValue = this.getNestedValue(credential, key);
if (credentialValue !== value) {
matches = false;
break;
}
}
if (matches) {
results.push(credential);
}
}
return results;
}
private getNestedValue(obj: any, path: string): unknown {
return path.split('.').reduce((current, key) => {
return current && typeof current === 'object' ? current[key] : undefined;
}, obj);
}
async clear(): Promise<void> {
this.credentials.clear();
}
async count(): Promise<number> {
return this.credentials.size;
}
}
export default JSONCredentialStorage;