zappier-edge/src/app.ts

256 lines
8.5 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';
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.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), (req, res) => {
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), (req, res) => {
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), (req, res) => {
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 };
}