Initial import of verae-middleware from zapier monorepo

This commit is contained in:
George Lambert 2026-09-11 16:18:23 -04:00
commit 90713dcc78
70 changed files with 6773 additions and 0 deletions

153
src/services/authService.js Normal file
View file

@ -0,0 +1,153 @@
/**
* @fileoverview Auth bridge: API keys / sessions Verae JWT context.
* @module services/authService
*/
import { veraeClient } from '../clients/veraeClient.js';
import { getTenantByApiKey, getTenant, upsertTenant } from '../store/tenants.js';
import { issueSessionToken, parseSessionToken, isApiKey } from '../lib/tokens.js';
import { AppError } from '../errors.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('auth');
/**
* Login with Verae username/password and issue middleware session token.
*
* @param {object} params
* @param {string} params.username
* @param {string} params.password
* @param {object} [params.tenant]
* @returns {Promise<{ accessToken: string, expiresAt: string, tenant: object, user: object }>}
*/
export async function loginWithCredentials({ username, password, tenant }) {
log.debug('loginWithCredentials', { username, tenantId: tenant?.id });
const verae = await veraeClient.login({ username, password });
if (tenant?.veraeUsername && tenant.veraeUsername !== username) {
throw new AppError('Credentials do not match tenant account', {
status: 403,
code: 'FORBIDDEN',
});
}
const tenantId = tenant?.id ?? `user:${verae.user.username}`;
const veraeUserId = verae.user?.id;
if (tenant && veraeUserId && tenant.veraeUserId !== veraeUserId) {
upsertTenant({ ...tenant, veraeUserId });
}
const accessToken = issueSessionToken({
tenantId,
veraeToken: verae.token,
expiresAt: verae.expiresAt,
});
return {
accessToken,
expiresAt: verae.expiresAt,
tenant: tenant
? { id: tenant.id, name: tenant.name, plan: tenant.plan, veraeUserId: veraeUserId || tenant.veraeUserId }
: { id: tenantId, name: verae.user.username, plan: 'free', veraeUserId },
user: verae.user,
veraeUserId,
};
}
/**
* Resolve API key to tenant and login with stored Verae credentials.
* @param {string} apiKey
* @returns {Promise<Awaited<ReturnType<typeof loginWithCredentials>>>}
*/
export async function loginWithApiKey(apiKey) {
const tenant = getTenantByApiKey(apiKey);
if (!tenant) {
log.debug('invalid api key');
throw new AppError('Invalid API key', { status: 401, code: 'UNAUTHORIZED' });
}
if (!tenant.veraeUsername || !tenant.veraePassword) {
throw new AppError('Tenant is missing Verae credentials', {
status: 500,
code: 'TENANT_MISCONFIGURED',
});
}
log.debug('loginWithApiKey', { tenantId: tenant.id, plan: tenant.plan });
return loginWithCredentials({
username: tenant.veraeUsername,
password: tenant.veraePassword,
tenant,
});
}
/**
* Resolve bearer credential into request auth context.
*
* @param {string|null} rawToken
* @returns {Promise<{ tenantId: string, tenant: object, veraeToken: string, authMethod: string }>}
*/
export async function resolveAuthContext(rawToken) {
if (!rawToken) {
throw new AppError('Missing authorization token', { status: 401, code: 'UNAUTHORIZED' });
}
if (isApiKey(rawToken)) {
const session = await loginWithApiKey(rawToken);
const parsed = parseSessionToken(session.accessToken);
const tenant = getTenant(session.tenant.id) ?? session.tenant;
return {
tenantId: session.tenant.id,
tenant: {
id: session.tenant.id,
name: session.tenant.name,
plan: session.tenant.plan,
veraeUserId: session.veraeUserId || tenant?.veraeUserId,
},
veraeToken: parsed.veraeToken,
veraeUserId: session.veraeUserId || tenant?.veraeUserId,
authMethod: 'api_key',
fullTenant: tenant,
};
}
const parsed = parseSessionToken(rawToken);
if (!parsed?.veraeToken) {
throw new AppError('Invalid or expired session token', { status: 401, code: 'UNAUTHORIZED' });
}
if (parsed.expiresAt && Date.parse(parsed.expiresAt) < Date.now()) {
throw new AppError('Session token expired', { status: 401, code: 'TOKEN_EXPIRED' });
}
const tenant = getTenant(parsed.tenantId);
log.debug('session auth', { tenantId: parsed.tenantId });
return {
tenantId: parsed.tenantId,
tenant: tenant
? { id: tenant.id, name: tenant.name, plan: tenant.plan, veraeUserId: tenant.veraeUserId }
: { id: parsed.tenantId, plan: 'free' },
veraeToken: parsed.veraeToken,
veraeUserId: tenant?.veraeUserId,
authMethod: 'session',
fullTenant: tenant,
};
}
/**
* Validate auth and optionally ping Verae /auth/validate.
* @param {string} rawToken
* @returns {Promise<object>}
*/
export async function validateSession(rawToken) {
const context = await resolveAuthContext(rawToken);
const validation = await veraeClient.validate(context.veraeToken);
return {
valid: true,
tenantId: context.tenantId,
plan: context.tenant.plan,
authMethod: context.authMethod,
user: validation,
};
}

View file

@ -0,0 +1,160 @@
/**
* @fileoverview Plan quotas and usage recording.
* @module services/entitlementService
*/
import { config } from '../config.js';
import { AppError } from '../errors.js';
import { getTenant, resolveLimits } from '../store/tenants.js';
import { getUsage, incrementUsage } from '../store/usage.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('billing');
function isUnlimited(value) {
return value === null || value === undefined;
}
function quotaExceeded({ limit, used, allowOverage }) {
if (isUnlimited(limit)) return false;
if (used < limit) return false;
return !allowOverage;
}
/**
* Ensure tenant may perform an action under plan limits.
*
* @param {string} tenantId
* @param {'timestamp'|'verify'|'batch_timestamp'} action
* @param {{ amount?: number }} [options]
* @returns {{ tenant: object, limits: object, usage: object }}
*/
export function checkEntitlement(tenantId, action, { amount = 1 } = {}) {
const tenant = getTenant(tenantId);
if (!tenant) {
throw new AppError('Unknown tenant', { status: 401, code: 'UNAUTHORIZED' });
}
const limits = resolveLimits(tenant);
const usage = getUsage(tenantId);
log.debug('checkEntitlement', {
tenantId,
action,
plan: tenant.plan,
amount,
timestampsUsed: usage.timestamps,
timestampsLimit: limits.timestamps,
});
if (action === 'batch_timestamp') {
if (!limits.batch) {
throw new AppError('Batch timestamps require a paid plan', {
status: 403,
code: 'PLAN_UPGRADE_REQUIRED',
details: { upgradeUrl: config.upgradeUrl },
});
}
if (!isUnlimited(limits.batchMaxItems) && amount > limits.batchMaxItems) {
throw new AppError(`Batch size exceeds plan limit of ${limits.batchMaxItems}`, {
status: 403,
code: 'BATCH_LIMIT_EXCEEDED',
details: { upgradeUrl: config.upgradeUrl, limit: limits.batchMaxItems },
});
}
}
if (action === 'timestamp') {
if (
quotaExceeded({
limit: limits.timestamps,
used: usage.timestamps,
allowOverage: limits.allowOverage,
})
) {
const err = new AppError('Monthly timestamp quota exceeded', {
status: 402,
code: 'QUOTA_EXCEEDED',
details: {
limit: limits.timestamps,
used: usage.timestamps,
period: 'monthly',
upgradeUrl: config.upgradeUrl,
},
});
throw err;
}
}
if (action === 'verify') {
if (
quotaExceeded({
limit: limits.verifications,
used: usage.verifications,
allowOverage: limits.allowOverage,
})
) {
throw new AppError('Monthly verification quota exceeded', {
status: 402,
code: 'QUOTA_EXCEEDED',
details: {
limit: limits.verifications,
used: usage.verifications,
period: 'monthly',
upgradeUrl: config.upgradeUrl,
},
});
}
}
return { tenant, limits, usage };
}
/**
* Record usage after a successful action.
*
* @param {string} tenantId
* @param {string} action
* @param {{ amount?: number }} [options]
* @returns {void}
*/
export function recordUsage(tenantId, action, { amount = 1 } = {}) {
const tenant = getTenant(tenantId);
if (!tenant) return;
const limits = resolveLimits(tenant);
const usage = getUsage(tenantId);
if (action === 'timestamp') {
if (
!isUnlimited(limits.timestamps) &&
usage.timestamps >= limits.timestamps &&
limits.allowOverage
) {
incrementUsage(tenantId, 'overage.timestamps', amount);
}
incrementUsage(tenantId, 'timestamps', amount);
}
if (action === 'verify') {
if (
!isUnlimited(limits.verifications) &&
usage.verifications >= limits.verifications &&
limits.allowOverage
) {
incrementUsage(tenantId, 'overage.verifications', amount);
}
incrementUsage(tenantId, 'verifications', amount);
}
if (action === 'status') {
incrementUsage(tenantId, 'statusChecks', amount);
}
if (action === 'batch_timestamp') {
incrementUsage(tenantId, 'batchTimestamps', amount);
incrementUsage(tenantId, 'timestamps', amount);
}
log.debug('recordUsage', { tenantId, action, amount });
}

View file

@ -0,0 +1,149 @@
/**
* @fileoverview Self-serve signup and admin tenant provisioning.
* @module services/tenantService
*/
import { randomUUID } from 'node:crypto';
import { createTenant, getTenant, listTenants } from '../store/tenants.js';
import { veraeClient } from '../clients/veraeClient.js';
import { AppError } from '../errors.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('auth');
const ALLOWED_PLANS = new Set(['free', 'starter', 'pro', 'enterprise']);
function slugify(value) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 48);
}
async function validateVeraeCredentials(username, password) {
try {
await veraeClient.login({ username, password });
return true;
} catch (err) {
throw new AppError('Invalid Verae credentials', {
status: 400,
code: 'INVALID_VERAE_CREDENTIALS',
details: { message: err.message },
});
}
}
/**
* Public free-tier signup.
* @param {{ email: string, name: string, veraeUsername: string, veraePassword: string }} params
*/
export async function selfServeSignup({ email, name, veraeUsername, veraePassword }) {
if (!email || !name || !veraeUsername || !veraePassword) {
throw new AppError('email, name, veraeUsername, and veraePassword are required', {
status: 400,
code: 'VALIDATION_ERROR',
});
}
const bound = await veraeClient.bindUser({ username: veraeUsername, password: veraePassword });
const id = `tenant-${slugify(email)}-${randomUUID().slice(0, 8)}`;
const { tenant, apiKey } = createTenant({
id,
name,
plan: 'free',
veraeUsername,
veraePassword,
veraeUserId: bound.veraeUserId,
contract: null,
metadata: { email, audience: 'self-serve', createdVia: 'signup' },
});
log.info('self-serve signup', { tenantId: tenant.id, veraeUserId: bound.veraeUserId });
return {
tenant: {
id: tenant.id,
name: tenant.name,
plan: tenant.plan,
email,
veraeUserId: bound.veraeUserId,
},
apiKey,
zapierSetup: {
authType: 'custom',
field: 'api_key',
middlewareUrl: '/zapier/v1/auth/me',
},
};
}
/**
* Admin provision (internal / enterprise).
*/
export async function provisionTenant({
id,
name,
plan,
veraeUsername,
veraePassword,
contract = null,
metadata = {},
audience = 'admin',
}) {
if (!name || !plan || !veraeUsername || !veraePassword) {
throw new AppError('name, plan, veraeUsername, and veraePassword are required', {
status: 400,
code: 'VALIDATION_ERROR',
});
}
if (!ALLOWED_PLANS.has(plan)) {
throw new AppError(`Invalid plan: ${plan}`, { status: 400, code: 'VALIDATION_ERROR' });
}
if (plan === 'enterprise' && !contract) {
throw new AppError('enterprise tenants require a contract object', {
status: 400,
code: 'VALIDATION_ERROR',
});
}
const bound = await veraeClient.bindUser({ username: veraeUsername, password: veraePassword });
const tenantId = id ?? `tenant-${slugify(name)}-${randomUUID().slice(0, 8)}`;
if (getTenant(tenantId)) {
throw new AppError('Tenant already exists', { status: 409, code: 'CONFLICT' });
}
const { tenant, apiKey } = createTenant({
id: tenantId,
name,
plan,
veraeUsername,
veraePassword,
veraeUserId: bound.veraeUserId,
contract,
metadata: { ...metadata, audience, createdVia: 'provision' },
});
log.info('tenant provisioned', { tenantId: tenant.id, plan, audience, veraeUserId: bound.veraeUserId });
return {
tenant: { id: tenant.id, name: tenant.name, plan: tenant.plan, veraeUserId: bound.veraeUserId },
apiKey,
};
}
/**
* @returns {Array<object>}
*/
export function listProvisionedTenants() {
return listTenants().map((tenant) => ({
id: tenant.id,
name: tenant.name,
plan: tenant.plan,
audience: tenant.metadata?.audience ?? 'unknown',
veraeUserId: tenant.veraeUserId,
createdAt: tenant.createdAt,
}));
}

View file

@ -0,0 +1,170 @@
/**
* @fileoverview Timestamp create/status operations with billing + job enqueue.
* @module services/timestampService
*/
import { config } from '../config.js';
import { veraeClient } from '../clients/veraeClient.js';
import { enqueueJob } from '../store/jobWatchers.js';
import { issueTokenRef } from '../store/tokenRefs.js';
import { checkEntitlement, recordUsage } from './entitlementService.js';
import { createDebugger } from '../debug/logger.js';
import { getTraceId } from '../debug/trace-context.js';
const log = createDebugger('jobs');
/**
* Enqueue async job watch via NATS or in-process store.
* @param {object} ctx
* @param {string} jobId
*/
async function enqueueWatchForJob(ctx, jobId) {
const traceId = getTraceId() ?? undefined;
if (config.natsEnabled) {
const { enqueueWatch } = await import('../nats/publishers.js');
await enqueueWatch({
tenantId: ctx.tenantId,
jobId,
tokenRef: issueTokenRef(ctx.tenantId),
veraeUserId: ctx.veraeUserId || ctx.tenant?.veraeUserId,
maxAttempts: config.jobPollMaxAttempts,
intervalMs: config.jobPollIntervalMs,
traceId,
});
log.debug('watch enqueued on NATS', { jobId, tenantId: ctx.tenantId });
return;
}
enqueueJob({
tenantId: ctx.tenantId,
jobId,
veraeToken: ctx.veraeToken,
traceId,
});
}
/**
* @param {object} ctx - Auth context with tenantId, veraeToken
* @param {{ data?: string, hashAlg?: string, sha256?: string, publicMetadata?: object, privateMetadata?: object }} body
* @returns {Promise<{ jobId: string, sha256?: string, existing?: boolean }>}
*/
export async function createTimestamp(ctx, body) {
checkEntitlement(ctx.tenantId, 'timestamp');
const result = await veraeClient.createTimestamp(ctx.veraeToken, body);
recordUsage(ctx.tenantId, 'timestamp');
await enqueueWatchForJob(ctx, result.jobId);
log.debug('timestamp created', { jobId: result.jobId, tenantId: ctx.tenantId });
return result;
}
/**
* @param {object} ctx
* @param {{ data: string, hashAlg?: string }} body
* @returns {Promise<object>} StatusResponse
*/
export async function createTimestampAndWait(ctx, body) {
const created = await createTimestamp(ctx, body);
if (config.natsEnabled) {
const { waitForJobEvent } = await import('../nats/wait.js');
const event = await waitForJobEvent(created.jobId, config.waitTimeoutMs);
if (event?.status) {
recordUsage(ctx.tenantId, 'status');
log.debug('wait via NATS event', {
jobId: created.jobId,
event: event.event,
traceId: event.traceId,
});
return event.status;
}
const status = await veraeClient.getStatus(ctx.veraeToken, created.jobId);
recordUsage(ctx.tenantId, 'status');
if (status.status === 'pending') {
return { id: created.jobId, jobId: created.jobId, status: 'pending' };
}
return status;
}
try {
const status = await veraeClient.waitForJob(ctx.veraeToken, created.jobId, {
maxAttempts: config.jobPollMaxAttempts,
intervalMs: config.jobPollIntervalMs,
});
recordUsage(ctx.tenantId, 'status');
return status;
} catch {
const status = await veraeClient.getStatus(ctx.veraeToken, created.jobId);
recordUsage(ctx.tenantId, 'status');
return { id: created.jobId, jobId: created.jobId, status: status.status ?? 'pending' };
}
}
/**
* @param {object} ctx
* @param {{ items: Array<{ data: string, hashAlg?: string }> }} body
*/
export async function createBatchTimestamp(ctx, body) {
const itemCount = body.items?.length ?? 0;
checkEntitlement(ctx.tenantId, 'batch_timestamp', { amount: itemCount });
const result = await veraeClient.createBatchTimestamp(ctx.veraeToken, body);
recordUsage(ctx.tenantId, 'batch_timestamp', { amount: itemCount });
for (const jobId of result.jobIds ?? []) {
await enqueueWatchForJob(ctx, jobId);
}
return result;
}
/**
* @param {object} ctx
* @param {string} jobId
*/
export async function getJobStatus(ctx, jobId) {
const status = await veraeClient.getStatus(ctx.veraeToken, jobId);
recordUsage(ctx.tenantId, 'status');
return status;
}
/**
* @param {object} ctx
* @param {{ jobIds: string[] }} body
*/
export async function getBatchJobStatus(ctx, body) {
const status = await veraeClient.getBatchStatus(ctx.veraeToken, body);
recordUsage(ctx.tenantId, 'status', { amount: body.jobIds?.length ?? 1 });
return status;
}
/**
* @param {object} ctx
* @param {string} jobId
*/
export async function getJobVerification(ctx, jobId) {
const status = await veraeClient.getJobVerification(ctx.veraeToken, jobId);
recordUsage(ctx.tenantId, 'status');
return status;
}
/**
* @param {object} ctx
* @param {string} sha256
* @param {{ includeAttached?: boolean, includeTree?: boolean }} [opts]
*/
export async function lookupHash(ctx, sha256, opts = {}) {
const result = await veraeClient.lookupHash(ctx.veraeToken, sha256);
recordUsage(ctx.tenantId, 'status');
if (!opts.includeAttached && !opts.includeTree) return result;
return {
...result,
includeAttached: Boolean(opts.includeAttached),
includeTree: Boolean(opts.includeTree),
note:
opts.includeTree && !result?.exists
? 'Chain miss: middleware should broadcast verae.archive.query kinds=["tree"] (wired in simulator; live aggregator hook TBD).'
: undefined,
};
}

View file

@ -0,0 +1,34 @@
/**
* @fileoverview Certificate verification with usage metering.
* @module services/verifyService
*/
import { veraeClient } from '../clients/veraeClient.js';
import { checkEntitlement, recordUsage } from './entitlementService.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('jobs');
/**
* @param {object} ctx
* @param {{ certificate: string }} body
*/
export async function verifyTimestamp(ctx, body) {
checkEntitlement(ctx.tenantId, 'verify');
const result = await veraeClient.verify(ctx.veraeToken, body);
recordUsage(ctx.tenantId, 'verify');
log.debug('verify result', { tenantId: ctx.tenantId, valid: result.valid });
return result;
}
/**
* @param {object} ctx
* @param {{ certificates: string[] }} body
*/
export async function verifyBatch(ctx, body) {
const amount = body.certificates?.length ?? 1;
checkEntitlement(ctx.tenantId, 'verify', { amount });
const result = await veraeClient.verifyBatch(ctx.veraeToken, body);
recordUsage(ctx.tenantId, 'verify', { amount });
return result;
}

View file

@ -0,0 +1,70 @@
/**
* @fileoverview REST Hook subscribe/unsubscribe and HTTP delivery.
* @module services/webhookService
*/
import { createWebhook, deleteWebhook } from '../store/webhooks.js';
import { createDebugger } from '../debug/logger.js';
const log = createDebugger('webhooks');
const ALLOWED_EVENTS = new Set(['timestamp.completed', 'timestamp.failed']);
/**
* @param {object} ctx - auth context
* @param {{ targetUrl: string, event: string }} params
*/
export function subscribe(ctx, { targetUrl, event }) {
if (!targetUrl) {
throw new Error('targetUrl is required');
}
if (!ALLOWED_EVENTS.has(event)) {
throw new Error(`Unsupported event: ${event}`);
}
return createWebhook({
tenantId: ctx.tenantId,
targetUrl,
event,
});
}
/**
* @param {object} ctx
* @param {{ hookId?: string, targetUrl?: string }} params
*/
export function unsubscribe(ctx, { hookId, targetUrl }) {
const removed = deleteWebhook({
tenantId: ctx.tenantId,
hookId,
targetUrl,
});
if (!removed) {
throw new Error('Webhook subscription not found');
}
return { removed: true };
}
/**
* POST payload to Zapier target URL.
* @param {string} targetUrl
* @param {object} payload
* @returns {Promise<{ ok: boolean, status: number }>}
*/
export async function deliverWebhook(targetUrl, payload) {
log.debug('deliverWebhook', { targetUrl, event: payload?.event });
const response = await fetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'Verae-Zapier-Middleware/1.0',
},
body: JSON.stringify(payload),
});
log.debug('deliverWebhook result', { status: response.status, ok: response.ok });
return { ok: response.ok, status: response.status };
}