/** * @fileoverview HTTP client for api.veraetime.net (with mock mode). * @module clients/veraeClient */ import { createHash, randomUUID } from 'node:crypto'; import { config } from '../config.js'; import { AppError } from '../errors.js'; import { createDebugger } from '../debug/logger.js'; const log = createDebugger('http'); const mockJobs = new Map(); /** @type {Map} */ const mockHashes = new Map(); /** * @param {string} data * @returns {string} */ export function sha256Hex(data) { return createHash('sha256').update(String(data), 'utf8').digest('hex'); } /** * @param {number} ms * @returns {Promise} */ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function mockLogin({ username, password }) { if (!username || !password) { throw new AppError('Invalid credentials', { status: 401, code: 'UNAUTHORIZED' }); } const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); return { token: `mock-jwt-${username}`, expiresAt, user: { id: randomUUID(), username, role: username.includes('admin') ? 'admin' : 'user', }, }; } async function mockValidate(token) { if (!token?.startsWith('mock-jwt-')) { throw new AppError('Invalid or expired token', { status: 401, code: 'UNAUTHORIZED' }); } const username = token.replace('mock-jwt-', ''); return { valid: true, userId: randomUUID(), username, role: 'user', expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), }; } async function mockCreateTimestamp({ data, hashAlg, sha256, publicMetadata, privateMetadata }) { if (!data && !sha256) { throw new AppError('Invalid input data', { status: 400, code: 'VALIDATION_ERROR' }); } const key = (sha256 || sha256Hex(data)).toLowerCase(); const existing = mockHashes.get(key); if (existing) { const job = mockJobs.get(existing.jobId); return { jobId: existing.jobId, sha256: key, existing: true, timestamp: job?.completedAt ?? existing.timestamp, }; } const jobId = randomUUID(); mockJobs.set(jobId, { id: jobId, status: 'pending', createdAt: Date.now(), data: data ?? key, hashAlg: hashAlg ?? 'SHA256', sha256: key, publicMetadata: publicMetadata ?? {}, privateMetadata: privateMetadata ?? {}, }); mockHashes.set(key, { jobId, sha256: key, publicMetadata, privateMetadata }); const delayMs = config.mockJobCompleteMs ?? 150; setTimeout(() => { const job = mockJobs.get(jobId); if (!job) return; job.status = 'completed'; job.result = `mock-cert-${jobId}`; job.completedAt = new Date().toISOString(); job.metadata = { blockIndex: 42, timestamp: job.completedAt, certificate: job.result, sha256: key, publicMetadata: job.publicMetadata, }; const rec = mockHashes.get(key); if (rec) rec.timestamp = job.completedAt; }, delayMs); return { jobId, sha256: key, existing: false }; } async function mockGetStatus(jobId) { const job = mockJobs.get(jobId); if (!job) { throw new AppError('Job not found', { status: 404, code: 'NOT_FOUND' }); } return { id: job.id, status: job.status, result: job.result, completedAt: job.completedAt, metadata: job.metadata, privateMetadata: job.privateMetadata, error: job.error, sha256: job.sha256, }; } async function mockLookupHash(sha256) { const key = String(sha256 || '').toLowerCase(); const rec = mockHashes.get(key); if (!rec) { throw new AppError('Hash not found', { status: 404, code: 'NOT_FOUND' }); } const job = mockJobs.get(rec.jobId); return { sha256: key, exists: true, jobId: rec.jobId, timestamp: rec.timestamp ?? job?.completedAt, status: job?.status, publicMetadata: job?.publicMetadata ?? rec.publicMetadata ?? {}, }; } async function mockVerify({ certificate }) { if (!certificate) { throw new AppError('Invalid input data', { status: 400, code: 'VALIDATION_ERROR' }); } const valid = certificate.startsWith('mock-cert-') || certificate.startsWith('eyJ'); return valid ? { valid: true, timestamp: new Date().toISOString(), blockIndex: 42 } : { valid: false }; } /** * Low-level fetch to Verae API. * @param {string} path * @param {{ method?: string, token?: string, body?: unknown }} [options] * @returns {Promise} */ async function request(path, { method = 'GET', token, body } = {}) { const url = `${config.veraeApiBaseUrl}${path}`; const headers = { Accept: 'application/json' }; if (token) { headers.Authorization = `Bearer ${token}`; } if (body !== undefined) { headers['Content-Type'] = 'application/json'; } const started = Date.now(); log.debug('verae request', { method, path, hasToken: Boolean(token) }); const response = await fetch(url, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, }); let payload = null; const text = await response.text(); if (text) { try { payload = JSON.parse(text); } catch { payload = { error: text }; } } log.debug('verae response', { method, path, status: response.status, durationMs: Date.now() - started, }); if (!response.ok) { throw new AppError(payload?.error ?? `Verae API error (${response.status})`, { status: response.status, code: payload?.code ?? 'VERAE_API_ERROR', details: payload, }); } return payload; } /** * Verae API client (mock when config.mockVerae is true). */ export const veraeClient = { /** * @param {{ username: string, password: string }} credentials */ async login(credentials) { if (config.mockVerae) return mockLogin(credentials); return request('/auth/login', { method: 'POST', body: credentials }); }, /** * @param {string} token */ async validate(token) { if (config.mockVerae) return mockValidate(token); return request('/auth/validate', { token }); }, /** * @param {string} token * @param {{ data: string, hashAlg?: string }} body */ async createTimestamp(token, body) { if (config.mockVerae) return mockCreateTimestamp(body); const liveBody = { data: body.data, hashAlg: body.hashAlg }; return request('/api/timestamp', { method: 'POST', token, body: liveBody }); }, /** * @param {string} token * @param {string} sha256 */ async lookupHash(token, sha256) { if (config.mockVerae) return mockLookupHash(sha256); throw new AppError('Hash lookup is not on the live Verae OpenAPI', { status: 501, code: 'NOT_IMPLEMENTED', }); }, /** * @param {string} token * @param {{ items: Array<{ data: string, hashAlg?: string }> }} body */ async createBatchTimestamp(token, body) { if (config.mockVerae) { const jobIds = []; for (const item of body.items ?? []) { const res = await mockCreateTimestamp(item); jobIds.push(res.jobId); } return { jobIds }; } return request('/api/batch/timestamp', { method: 'POST', token, body }); }, /** * @param {string} token * @param {string} jobId */ async getStatus(token, jobId) { if (config.mockVerae) return mockGetStatus(jobId); return request(`/api/status/${encodeURIComponent(jobId)}`, { token }); }, /** * @param {string} token * @param {{ jobIds: string[] }} body */ async getBatchStatus(token, body) { if (config.mockVerae) { const results = {}; for (const jobId of body.jobIds ?? []) { results[jobId] = await mockGetStatus(jobId); } return { results }; } return request('/api/batch/status', { method: 'POST', token, body }); }, /** * @param {string} token * @param {{ certificate: string }} body */ async verify(token, body) { if (config.mockVerae) return mockVerify(body); return request('/api/verify', { method: 'POST', token, body }); }, /** * @param {string} token * @param {{ certificates: string[] }} body */ async verifyBatch(token, body) { if (config.mockVerae) { const results = []; for (const certificate of body.certificates ?? []) { results.push(await mockVerify({ certificate })); } return { results }; } return request('/api/batch/verify', { method: 'POST', token, body }); }, /** * @param {string} token * @param {string} jobId */ async getJobVerification(token, jobId) { if (config.mockVerae) return mockGetStatus(jobId); return request(`/api/verify/${encodeURIComponent(jobId)}`, { token }); }, /** * Poll until completed/failed or timeout. * @param {string} token * @param {string} jobId * @param {{ maxAttempts: number, intervalMs: number }} options */ async waitForJob(token, jobId, { maxAttempts, intervalMs }) { for (let attempt = 0; attempt < maxAttempts; attempt += 1) { const status = await this.getStatus(token, jobId); if (status.status === 'completed' || status.status === 'failed') { return status; } await delay(intervalMs); } throw new AppError(`Job ${jobId} timed out`, { status: 504, code: 'GATEWAY_TIMEOUT' }); }, }; /** * Clear mock jobs (tests only). * @returns {void} */ export function clearMockJobs() { mockJobs.clear(); mockHashes.clear(); }