264 lines
8.9 KiB
TypeScript
264 lines
8.9 KiB
TypeScript
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<string, unknown>;
|
|
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<string>;
|
|
}
|
|
|
|
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<string, { jobId: string; sha256: string; data?: string; timestamp: string }>();
|
|
|
|
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<string, unknown>;
|
|
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 };
|
|
}
|