import path from 'path'; import { createHash, randomUUID } from 'crypto'; import express, { Express, NextFunction, Request, RequestHandler, Response } from 'express'; import * as OpenApiValidator from 'express-openapi-validator'; import swaggerUi from 'swagger-ui-express'; import YAML from 'yamljs'; import { apiKeyAuth, Customer, CustomerRepo, InMemoryCustomerRepo, } from './auth'; import { meter } from './meter'; import { applyMonthlyCredit } from './billing/credit'; import { ConfigTierCatalog, InMemoryPricingStore, PricingContext, PricingStore, } from './pricing'; import { InMemoryUsageRepo, UsageRepo } from './usage'; import { adminAuth, adminLoginRouter, adminRouter } from './admin'; import { AdminUserRepo, InMemoryAdminUserRepo, seedAdminUsersFromEnv } from './admin-users'; import { InMemorySessionRepo, SessionRepo } from './accounts'; import { InMemoryInvoiceRepo, InvoiceRepo } from './invoicing'; import { PROJECT_ROOT } from './paths'; import { PaymentClient, portalRouter } from './portal'; import { proxyVerae } from './upstream'; export interface StoredItem { id: string; customerId: string; metadata: Record; attachments: { filename: string; size: number }[]; createdAt: string; } export const DEFAULT_CUSTOMERS: Customer[] = [ { id: 'cust_1', name: 'Ada (free)', tierId: 'free', apiKey: 'key-ada' }, { id: 'cust_2', name: 'Grace (pro)', tierId: 'pro', apiKey: 'key-grace' }, { id: 'cust_3', name: 'Linus (business)', tierId: 'business', apiKey: 'key-linus' }, ]; export interface AppDeps { usage?: UsageRepo; customers?: CustomerRepo; pricingStore?: PricingStore; invoices?: InvoiceRepo; adminUsers?: AdminUserRepo; sessions?: SessionRepo; payments?: PaymentClient; /** QR renderer for 2FA setup; defaults to the qrcode package. */ qr?: (uri: string) => Promise; } const SPEC_PATH = path.join(PROJECT_ROOT, 'openapi.yaml'); const parseMetadata: RequestHandler = (req, res, next) => { const raw = req.body?.metadata; if (raw === undefined || raw === null || raw === '') { res.locals.parsedMetadata = {}; return next(); } if (typeof raw !== 'string') { res.locals.parsedMetadata = raw; return next(); } try { res.locals.parsedMetadata = JSON.parse(raw); next(); } catch { res.status(400).json({ error: 'invalid metadata JSON' }); } }; export function buildApp(deps: AppDeps = {}): { app: Express; usage: UsageRepo; customers: CustomerRepo; pricing: PricingContext; pricingStore: PricingStore; invoices: InvoiceRepo; items: StoredItem[]; } { const customers = deps.customers ?? new InMemoryCustomerRepo(DEFAULT_CUSTOMERS); const usage = deps.usage ?? new InMemoryUsageRepo(); const pricingStore = deps.pricingStore ?? new InMemoryPricingStore(); const invoices = deps.invoices ?? new InMemoryInvoiceRepo(); const adminUsers = deps.adminUsers ?? InMemoryAdminUserRepo.seeded(seedAdminUsersFromEnv()); // Live pricing context: every quote reads the store, so admin edits apply immediately. const pricing: PricingContext = { get rateCard() { return pricingStore.getRateCard(); }, get tiers() { return new ConfigTierCatalog(pricingStore.getTiers()); }, }; const items: StoredItem[] = []; const hashIndex = new Map< string, { jobId: string; sha256: string; data?: string; timestamp: string } >(); const jobIndex = new Map(); const app = express(); app.use(express.json()); const spec = YAML.load(SPEC_PATH); app.use('/docs', swaggerUi.serve, swaggerUi.setup(spec)); app.use( '/admin/api', adminLoginRouter(adminUsers), adminAuth(), adminRouter(pricingStore, customers, { usage, invoices, users: adminUsers }), ); app.use('/admin', express.static(path.join(PROJECT_ROOT, 'admin'))); const sessions = deps.sessions ?? new InMemorySessionRepo(); const qr = deps.qr ?? (async (uri: string) => { const qrcode = await import('qrcode'); return qrcode.toDataURL(uri, { margin: 1, width: 220 }); }); const payments: PaymentClient = deps.payments ?? { // Safe default for tests/dev: credits immediately, no Stripe call. reload: async (_customer, amountCents) => ({ mode: 'dev', creditedCents: amountCents }), }; app.use( '/portal/api', portalRouter({ customers, sessions, usage, invoices, tiers: () => pricingStore.getTiers(), rateCard: () => pricingStore.getRateCard(), payments, qr, }), ); app.use('/portal', express.static(path.join(PROJECT_ROOT, 'portal'))); app.get('/health', (_req, res) => { res.json({ ok: true, role: 'zappier-edge' }); }); app.use('/v1', apiKeyAuth(customers)); app.use( OpenApiValidator.middleware({ apiSpec: SPEC_PATH, validateRequests: true, validateResponses: false, fileUploader: { limits: { fileSize: 25 * 1024 * 1024 } }, }), ); app.get('/v1/status', meter('status', usage, pricing), (req, res) => { res.json({ status: 'ok', quote: res.locals.quote }); }); app.post('/v1/transform', meter('transform', usage, pricing), (req, res) => { const text = String(req.body?.text ?? ''); res.json({ output: text.toUpperCase(), quote: res.locals.quote }); }); app.post('/v1/timestamp', meter('timestamp', usage, pricing), async (req, res) => { if (await proxyVerae(req, res, '/zapier/v1/timestamp')) return; const data = req.body?.data != null ? String(req.body.data) : ''; const sha256 = (req.body?.sha256 && String(req.body.sha256).toLowerCase()) || (data ? createHash('sha256').update(data, 'utf8').digest('hex') : ''); if (!sha256) { res.status(400).json({ error: 'data or sha256 is required' }); return; } const existing = hashIndex.get(sha256); if (existing) { res.status(202).json({ jobId: existing.jobId, sha256, existing: true, timestamp: existing.timestamp }); return; } const jobId = randomUUID(); const timestamp = new Date().toISOString(); const rec = { jobId, sha256, data, timestamp }; hashIndex.set(sha256, rec); jobIndex.set(jobId, rec); res.status(202).json({ jobId, sha256, existing: false, timestamp }); }); app.get('/v1/receipts/:jobId', meter('receipt', usage, pricing), async (req, res) => { if (await proxyVerae(req, res, `/zapier/v1/receipts/${req.params.jobId}`)) return; const rec = jobIndex.get(String(req.params.jobId)); if (!rec) { res.status(404).json({ error: 'Job not found' }); return; } res.json({ type: 'verae.retrieval-receipt', format: 'json', jobId: rec.jobId, sha256: rec.sha256, timestamp: rec.timestamp, extraSeal: { event: 'document.retrieved', retrievedAt: new Date().toISOString() }, quote: res.locals.quote, }); }); app.get('/v1/hashes/:sha256', meter('hash-lookup', usage, pricing), async (req, res) => { if (await proxyVerae(req, res, `/zapier/v1/hashes/${req.params.sha256}`)) return; const sha256 = String(req.params.sha256 || '').toLowerCase(); const rec = hashIndex.get(sha256); if (!rec) { res.status(404).json({ error: 'Hash not found' }); return; } res.json({ exists: true, ...rec }); }); app.post('/v1/add', meter('add', usage, pricing), (req, res) => { const number1 = Number(req.body?.number1); const number2 = Number(req.body?.number2); if (!Number.isFinite(number1) || !Number.isFinite(number2)) { res.status(400).json({ error: 'number1 and number2 must be finite numbers' }); return; } res.json({ number1, number2, sum: number1 + number2, quote: res.locals.quote }); }); app.post('/v1/storage', parseMetadata, meter('storage', usage, pricing), (req, res) => { const metadata = res.locals.parsedMetadata as Record; const files = (req.files as Express.Multer.File[]) ?? []; const item: StoredItem = { id: randomUUID(), customerId: req.customer!.id, metadata, attachments: files.map((f) => ({ filename: f.originalname, size: f.size })), createdAt: new Date().toISOString(), }; items.unshift(item); res.json({ id: item.id, quote: res.locals.quote }); }); app.get('/v1/storage', meter('storage-list', usage, pricing), (req, res) => { res.json({ items: items.filter((i) => i.customerId === req.customer!.id) }); }); app.get('/v1/usage', (req, res) => { const since = new Date(); since.setUTCDate(1); since.setUTCHours(0, 0, 0, 0); const summary = usage.summaryFor(req.customer!.id, since); const tier = pricing.tiers.find(req.customer!.tierId); res.json(tier ? applyMonthlyCredit(summary, tier) : summary); }); app.use((err: Error & { status?: number }, req: Request, res: Response, next: NextFunction) => { res.status(err.status ?? 500).json({ error: err.message }); }); return { app, usage, customers, pricing, pricingStore, invoices, items }; }