Initial import of zappier-edge from zapier monorepo
This commit is contained in:
commit
78e6201d43
120 changed files with 19867 additions and 0 deletions
256
src/app.ts
Normal file
256
src/app.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue