@vqp/core
Version:
Core VQP protocol implementation - adapter-agnostic
438 lines • 17.9 kB
JavaScript
/**
* Core VQP Service - Pure business logic following hexagonal architecture
* This service is completely isolated from external concerns
*/
import { VQPError } from './types.js';
/**
* Default vocabulary mapping for standard VQP vocabularies
*/
export class StandardVocabularyMapping {
toVaultPath(field, vocabularyUri) {
// If field already contains dots, split as-is
if (field.includes('.')) {
return field.split('.');
}
// Map vocabulary fields to vault structure
if (vocabularyUri) {
switch (vocabularyUri) {
case 'vqp:identity:v1':
return ['personal', field];
case 'vqp:financial:v1':
return ['financial', field];
case 'vqp:health:v1':
return ['health', field];
case 'vqp:academic:v1':
return ['academic', field];
case 'vqp:metrics:v1':
return ['system', field];
default:
// Unknown vocabulary, assume top-level
return [field];
}
}
// Default: assume top-level field
return [field];
}
toVocabularyField(path, vocabularyUri) {
// If path has multiple segments and matches known patterns, extract field name
if (path.length >= 2 && vocabularyUri) {
const category = path[0];
const field = path[1];
if (category && field) {
switch (vocabularyUri) {
case 'vqp:identity:v1':
if (category === 'personal')
return field;
break;
case 'vqp:financial:v1':
if (category === 'financial')
return field;
break;
case 'vqp:health:v1':
if (category === 'health')
return field;
break;
case 'vqp:academic:v1':
if (category === 'academic')
return field;
break;
case 'vqp:metrics:v1':
if (category === 'system')
return field;
break;
}
}
}
// Fallback: use last segment or join with dots
const lastSegment = path[path.length - 1];
return path.length === 1 && lastSegment ? lastSegment : path.join('.');
}
}
/**
* Simple flat vocabulary mapping (all fields at top level)
*/
export class FlatVocabularyMapping {
toVaultPath(field, _vocabularyUri) {
return field.includes('.') ? field.split('.') : [field];
}
toVocabularyField(path, _vocabularyUri) {
return path.join('.');
}
}
export class VQPService {
dataAccess;
crypto;
audit;
queryEvaluation;
responseMode;
vocabulary;
config;
vocabularyMapping;
constructor(dataAccess, crypto, audit, queryEvaluation, responseMode, // New: Response Mode port
vocabulary, // Now optional
config = {}) {
this.dataAccess = dataAccess;
this.crypto = crypto;
this.audit = audit;
this.queryEvaluation = queryEvaluation;
this.responseMode = responseMode;
this.vocabulary = vocabulary;
this.config = config;
// Use provided mapping or default to standard mapping
this.vocabularyMapping = config.vocabularyMapping || new StandardVocabularyMapping();
}
/**
* Main entry point for processing VQP queries
*
* @param query The VQP query to process
* @param providedVocabulary Optional vocabulary schema. If not provided, will use vocabulary adapter
*/
async processQuery(query, providedVocabulary) {
try {
// 1. Validate query structure and timing
await this.validateQueryStructure(query);
// 2. Resolve vocabulary (from parameter or adapter)
const vocabulary = await this.getVocabulary(query.query.vocab, providedVocabulary);
// 3. Check if vocabulary is allowed
if (!(await this.isVocabularyAllowed(query.query.vocab))) {
throw this.createError('UNAUTHORIZED', `Vocabulary not allowed: ${query.query.vocab}`);
}
// 4. Validate query expression against vocabulary
await this.validateAgainstVocabulary(query.query.expr, vocabulary);
// 5. Extract required data paths from query expression
const requiredPaths = this.extractDataPaths(query.query.expr, query.query.vocab);
// 6. Check data access permissions
for (const path of requiredPaths) {
if (!(await this.dataAccess.validateDataAccess(path, query.requester))) {
throw this.createError('UNAUTHORIZED', `Access denied to data path: ${path.join('.')}`);
}
}
// 7. Gather required data
const data = await this.gatherData(requiredPaths, query.query.vocab);
// 8. Evaluate query using the injected evaluation adapter
const result = await this.evaluateQuery(query.query.expr, data);
// 9. Generate timestamp for response
const responseTimestamp = new Date().toISOString();
const responderDID = await this.getResponderDID();
// 10. Process response mode (NEW)
const responseModeResult = await this.responseMode.processResponseMode(query, result, result // actualValue - same as result for now
);
// 11. Generate cryptographic proof using the processed result
const proof = await this.generateProof(query, responseModeResult.result, responseTimestamp, responderDID);
// 12. Create response with response mode data
const response = {
queryId: query.id,
version: query.version,
timestamp: responseTimestamp,
responder: responderDID,
mode: responseModeResult.mode,
result: responseModeResult.result,
proof,
// Optional mode-specific fields
...(responseModeResult.value !== undefined && { value: responseModeResult.value }),
...(responseModeResult.additionalData && {
...(responseModeResult.additionalData.consentProof && {
consentProof: responseModeResult.additionalData.consentProof,
}),
...(responseModeResult.additionalData.mutualProof && {
mutualProof: responseModeResult.additionalData.mutualProof,
}),
...(responseModeResult.additionalData.obfuscationApplied && {
obfuscationApplied: responseModeResult.additionalData.obfuscationApplied,
}),
}),
};
// 13. Log successful query processing
await this.audit.logQuery(query, response);
return response;
}
catch (error) {
// Log error and re-throw
const vqpError = error instanceof Error
? this.createError('EVALUATION_ERROR', error.message)
: error;
await this.audit.logError(vqpError, { query });
throw vqpError;
}
}
/**
* Validate the basic structure and timing of a query
*/
async validateQueryStructure(query) {
// Check required fields
if (!query.id || !query.version || !query.timestamp || !query.requester || !query.query) {
throw this.createError('INVALID_QUERY', 'Missing required fields');
}
// Validate UUID format
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(query.id)) {
throw this.createError('INVALID_QUERY', 'Invalid query ID format');
}
// Validate timestamp (not too old, not in future)
const queryTime = new Date(query.timestamp).getTime();
const now = Date.now();
const maxAge = 5 * 60 * 1000; // 5 minutes
const maxSkew = 1 * 60 * 1000; // 1 minute future
if (now - queryTime > maxAge) {
throw this.createError('INVALID_QUERY', 'Query timestamp too old');
}
if (queryTime - now > maxSkew) {
throw this.createError('INVALID_QUERY', 'Query timestamp in future');
}
// Validate query language
if (query.query.lang !== 'jsonlogic@1.0.0') {
throw this.createError('INVALID_QUERY', `Unsupported query language: ${query.query.lang}`);
}
// Check query complexity
if (this.config.maxQueryComplexity) {
const complexity = this.calculateQueryComplexity(query.query.expr);
if (complexity > this.config.maxQueryComplexity) {
throw this.createError('INVALID_QUERY', 'Query too complex');
}
}
}
/**
* Extract data paths from JSONLogic expression and map them to vault paths
*/
extractDataPaths(expr, vocabularyUri) {
const paths = [];
const traverse = (obj) => {
if (typeof obj === 'object' && obj !== null) {
if (obj.var && typeof obj.var === 'string') {
// This is a variable reference - map it to vault path
const vaultPath = this.mapVocabularyToVaultPath(obj.var, vocabularyUri);
paths.push(vaultPath);
}
else {
// Recursively traverse object properties
Object.values(obj).forEach(traverse);
}
}
else if (Array.isArray(obj)) {
obj.forEach(traverse);
}
};
traverse(expr);
return paths;
}
/**
* Map vocabulary field to actual vault path using configurable mapping
*/
mapVocabularyToVaultPath(field, vocabularyUri) {
return this.vocabularyMapping.toVaultPath(field, vocabularyUri);
}
/**
* Gather data for all required paths and reconstruct for vocabulary format
*/
async gatherData(paths, vocabularyUri) {
const data = {};
for (const path of paths) {
try {
const value = await this.dataAccess.getData(path);
// Map vault path back to vocabulary field for JSONLogic evaluation
const vocabularyField = this.mapVaultPathToVocabulary(path, vocabularyUri);
data[vocabularyField] = value;
}
catch (error) {
// If data is not available, continue - JSONLogic will handle undefined
console.warn(`Data not available for path: ${path.join('.')}`);
}
}
return data;
}
/**
* Map vault path back to vocabulary field name using configurable mapping
*/
mapVaultPathToVocabulary(path, vocabularyUri) {
return this.vocabularyMapping.toVocabularyField(path, vocabularyUri);
}
/**
* Evaluate query using the injected evaluation port
*/
async evaluateQuery(expr, data) {
try {
return await this.queryEvaluation.evaluate(expr, data);
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
throw this.createError('EVALUATION_ERROR', `Query evaluation failed: ${errorMessage}`);
}
}
/**
* Generate cryptographic proof for the response
*/
async generateProof(query, result, timestamp, responderDID) {
const responsePayload = {
queryId: query.id,
result,
timestamp,
responder: responderDID,
};
const dataToSign = Buffer.from(JSON.stringify(responsePayload));
return await this.crypto.sign(dataToSign, 'default');
}
/**
* Calculate query complexity (for rate limiting)
*/
calculateQueryComplexity(expr) {
let complexity = 0;
const traverse = (obj) => {
complexity++;
if (typeof obj === 'object' && obj !== null) {
Object.values(obj).forEach(traverse);
}
else if (Array.isArray(obj)) {
obj.forEach(traverse);
}
};
traverse(expr);
return complexity;
}
/**
* Get the DID of this responder
*/
async getResponderDID() {
// This would typically be configured or derived from the cryptographic key
return 'did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp';
}
/**
* Create a VQP error with consistent structure
*/
createError(code, message, details) {
return new VQPError(code, message, details);
}
/**
* Get vocabulary either from provided parameter or vocabulary adapter
*/
async getVocabulary(uri, providedVocabulary) {
if (providedVocabulary) {
return providedVocabulary;
}
if (this.vocabulary) {
const vocabulary = await this.vocabulary.resolveVocabulary(uri);
if (!vocabulary) {
throw this.createError('VOCABULARY_NOT_FOUND', `Vocabulary not found: ${uri}`);
}
return vocabulary;
}
throw this.createError('VOCABULARY_NOT_FOUND', `No vocabulary provided for ${uri} and no vocabulary resolver configured. Pass vocabulary directly or configure a vocabulary adapter.`);
}
/**
* Check if vocabulary is allowed
*/
async isVocabularyAllowed(uri) {
// Check config-based allowed vocabularies
if (this.config.allowedVocabularies) {
if (!this.config.allowedVocabularies.includes(uri) &&
!this.config.allowedVocabularies.includes('*')) {
return false;
}
}
// Check vocabulary adapter if available
if (this.vocabulary) {
return await this.vocabulary.isVocabularyAllowed(uri);
}
return true; // No restrictions if no vocabulary adapter
}
/**
* Validate query expression against vocabulary
*/
async validateAgainstVocabulary(expr, vocabulary) {
// Use vocabulary adapter if available for basic checks
if (this.vocabulary) {
// Only validate basic structure, not strict property existence
// Let JSONLogic handle undefined properties naturally (they become false/undefined)
const hasValidStructure = await this.vocabulary.validateAgainstVocabulary(expr, vocabulary);
if (!hasValidStructure) {
// Only throw error for structural issues, not missing properties
const hasUnknownProperties = this.hasUnknownProperties(expr, vocabulary);
if (!hasUnknownProperties) {
throw this.createError('INVALID_QUERY', 'Query expression has invalid structure');
}
// If it's just unknown properties, let it continue and return false naturally
}
return;
}
// Basic validation if no vocabulary adapter
await this.basicVocabularyValidation(expr, vocabulary);
}
/**
* Check if expression contains unknown properties (but don't treat as error)
*/
hasUnknownProperties(expr, vocabulary) {
if (!vocabulary || !vocabulary.properties) {
return false;
}
const checkProperties = (obj) => {
if (typeof obj === 'object' && obj !== null && !Array.isArray(obj)) {
if (obj.var && typeof obj.var === 'string') {
const varPath = obj.var.split('.');
return !vocabulary.properties[varPath[0]];
}
return Object.values(obj).some((value) => checkProperties(value));
}
else if (Array.isArray(obj)) {
return obj.some((item) => checkProperties(item));
}
return false;
};
return checkProperties(expr);
}
/**
* Basic vocabulary validation when no adapter is available
*/
async basicVocabularyValidation(expr, vocabulary) {
if (!vocabulary || !vocabulary.properties) {
return; // No validation constraints
}
// Extract variables used in the expression
const usedVars = await this.queryEvaluation.extractVariables(expr);
// Check if all used variables are defined in vocabulary
for (const variable of usedVars) {
const varPath = variable.split('.');
if (!this.isPathAllowedInVocabulary(varPath, vocabulary)) {
throw this.createError('INVALID_QUERY', `Variable '${variable}' not allowed in vocabulary`);
}
}
}
/**
* Check if a data path is allowed by the vocabulary
*/
isPathAllowedInVocabulary(path, vocabulary) {
if (!vocabulary.properties) {
return true; // No restrictions
}
let current = vocabulary.properties;
for (const segment of path) {
if (!current[segment]) {
return false; // Path not defined in vocabulary
}
// Move to nested properties if they exist
if (current[segment].properties) {
current = current[segment].properties;
}
}
return true;
}
}
//# sourceMappingURL=vqp-service.js.map