Around remaining blockers (Zapier login, live Verae): generate PDF receipts without extra deps; AES-256-GCM object store with tenant isolation and share tokens; scripts/nats-tunnel.sh to NS1; SHA256 Hash Text local Zapier action; scripts/test-offline.sh for the no-login suite. Learned: other-tenant object GET is 403; share token is the mock unwrap path.
351 lines
9.3 KiB
JavaScript
351 lines
9.3 KiB
JavaScript
/**
|
|
* @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<string, { jobId: string, sha256: string, timestamp?: string, publicMetadata?: object, privateMetadata?: object }>} */
|
|
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<void>}
|
|
*/
|
|
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<any>}
|
|
*/
|
|
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();
|
|
}
|