Milestone 0: import zappier billing, Verae middleware, and Zapier research

Compose-ready workspace: packages/zappier (rate card, portal, Stripe),
packages/verae-zapier-middleware (timestamp + NATS), packages/verae-zapier
(CLI app), vendor/zapier-platform, and research/zapier vendor corpus.

Gate 0 structure checks pass. Product code and research are not yet wired.
This commit is contained in:
George Lambert 2026-09-09 02:37:36 -04:00
commit b4150c8250
1364 changed files with 6814366 additions and 0 deletions

View file

@ -0,0 +1,284 @@
import { randomBytes } from 'crypto';
import { RequestHandler, Router } from 'express';
import {
generateTotpSecret,
hashPassword,
SessionRepo,
totpUri,
verifyPassword,
verifyTotp,
} from './accounts';
import { renderInvoiceHtml } from './admin';
import { Customer, CustomerRepo } from './auth';
import { applyMonthlyCredit } from './billing/credit';
import { InvoiceRepo } from './invoicing';
import { RateCard, TierConfig } from './pricing';
import { UsageRepo } from './usage';
/**
* Customer portal API (/portal/api): signup, login with optional TOTP 2FA,
* profile, API-key regeneration, usage, own invoices, prepaid reloads, and
* email-invoicing preferences. Sessions authenticate via Bearer token.
*/
export interface PaymentResult {
mode: 'stripe' | 'dev';
/** Cents actually credited to the balance now (0 while a Stripe intent awaits confirmation). */
creditedCents: number;
clientSecret?: string;
}
export interface PaymentClient {
reload(customer: Customer, amountCents: number): Promise<PaymentResult>;
}
export interface PortalDeps {
customers: CustomerRepo;
sessions: SessionRepo;
usage: UsageRepo;
invoices: InvoiceRepo;
tiers: () => TierConfig[];
rateCard: () => RateCard;
payments: PaymentClient;
/** Renders an otpauth URI as a QR data URL; injectable for tests. */
qr: (uri: string) => Promise<string>;
sessionTtlMs?: number;
now?: () => number;
}
const DEFAULT_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MIN_RELOAD_CENTS = 100; // $1
const MAX_RELOAD_CENTS = 1_000_000; // $10,000
/** Profile payload safe to return to the customer (no hashes/secrets). */
function publicProfile(c: Customer) {
return {
id: c.id,
name: c.name,
email: c.email,
tierId: c.tierId,
billingType: c.billingType ?? 'stripe',
apiKey: c.apiKey,
balanceCents: c.balanceCents ?? 0,
totpEnabled: c.totpEnabled ?? false,
emailInvoicing: c.emailInvoicing ?? false,
};
}
function sessionAuth(deps: PortalDeps): RequestHandler {
return (req, res, next) => {
const bearer = req.header('authorization');
const token = bearer?.startsWith('Bearer ') ? bearer.slice(7) : undefined;
const session = token ? deps.sessions.get(token, (deps.now ?? Date.now)()) : undefined;
const customer = session
? deps.customers.list().find((c) => c.id === session.customerId)
: undefined;
if (!customer) {
res.status(401).json({ error: 'invalid or expired session' });
return;
}
req.customer = customer;
next();
};
}
function save(deps: PortalDeps, customer: Customer): void {
deps.customers.save(customer);
}
export function portalRouter(deps: PortalDeps): Router {
const router = Router();
const ttl = deps.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
const now = deps.now ?? Date.now;
/* ---------------- auth ---------------- */
router.post('/signup', (req, res) => {
const { name, email, password } = req.body ?? {};
if (typeof name !== 'string' || name.trim().length === 0) {
res.status(400).json({ error: 'name is required' });
return;
}
if (typeof email !== 'string' || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
res.status(400).json({ error: 'valid email is required' });
return;
}
if (typeof password !== 'string' || password.length < 8) {
res.status(400).json({ error: 'password must be at least 8 characters' });
return;
}
const passwordHash = hashPassword(password);
const existing = deps.customers.findByEmail(email);
let customer: Customer;
if (existing) {
if (existing.passwordHash) {
res.status(409).json({ error: 'an account with this email already exists' });
return;
}
// Claim flow: an admin-created customer sets their portal password once.
customer = { ...existing, name: existing.name || name.trim(), email, passwordHash };
} else {
customer = {
id: `cust_${randomBytes(4).toString('hex')}`,
name: name.trim(),
email,
tierId: 'free',
apiKey: `key-${randomBytes(8).toString('hex')}`,
billingType: 'stripe',
passwordHash,
balanceCents: 0,
};
}
save(deps, customer);
const session = deps.sessions.create(customer.id, ttl);
res.status(201).json({ token: session.token, customer: publicProfile(customer) });
});
router.post('/login', (req, res) => {
const { email, password, totpCode } = req.body ?? {};
if (typeof email !== 'string' || typeof password !== 'string') {
res.status(400).json({ error: 'email and password are required' });
return;
}
const customer = deps.customers.findByEmail(email);
if (!customer?.passwordHash || !verifyPassword(password, customer.passwordHash)) {
res.status(401).json({ error: 'invalid email or password' });
return;
}
if (customer.totpEnabled) {
if (
typeof totpCode !== 'string' ||
!customer.totpSecret ||
!verifyTotp(customer.totpSecret, totpCode, now())
) {
res.status(401).json({ error: 'totp_required' });
return;
}
}
const session = deps.sessions.create(customer.id, ttl);
res.json({ token: session.token, customer: publicProfile(customer) });
});
router.use(sessionAuth(deps));
router.post('/logout', (req, res) => {
const bearer = req.header('authorization')!;
deps.sessions.delete(bearer.slice(7));
res.json({ ok: true });
});
/* ---------------- profile + API key ---------------- */
router.get('/me', (req, res) => {
res.json(publicProfile(req.customer!));
});
router.post('/api-key', (req, res) => {
const customer = { ...req.customer!, apiKey: `key-${randomBytes(8).toString('hex')}` };
save(deps, customer);
res.json({ apiKey: customer.apiKey });
});
/* ---------------- usage + invoices ---------------- */
router.get('/usage', (req, res) => {
const since = new Date(now());
since.setUTCDate(1);
since.setUTCHours(0, 0, 0, 0);
const summary = deps.usage.summaryFor(req.customer!.id, since);
const tier = deps.tiers().find((t) => t.id === req.customer!.tierId);
res.json(tier ? applyMonthlyCredit(summary, tier) : summary);
});
router.get('/invoices', (req, res) => {
res.json({ invoices: deps.invoices.list({ customerId: req.customer!.id }) });
});
router.get('/pricing', (req, res) => {
res.json({ tiers: deps.tiers(), rateCard: deps.rateCard() });
});
router.get('/invoices/:id', (req, res) => {
const invoice = deps.invoices.get(req.params.id);
if (!invoice || invoice.customerId !== req.customer!.id) {
res.status(404).json({ error: 'invoice not found' });
return;
}
if (req.query.format === 'html') {
res.type('html').send(renderInvoiceHtml(invoice, req.customer!.name));
return;
}
res.json(invoice);
});
/* ---------------- 2FA ---------------- */
router.post('/2fa/setup', async (req, res) => {
const secret = generateTotpSecret();
save(deps, { ...req.customer!, totpSecret: secret, totpEnabled: false });
const uri = totpUri(secret, req.customer!.email ?? req.customer!.id);
res.json({ secret, uri, qr: await deps.qr(uri) });
});
router.post('/2fa/enable', (req, res) => {
const { code } = req.body ?? {};
const secret = req.customer!.totpSecret;
if (!secret || typeof code !== 'string' || !verifyTotp(secret, code, now())) {
res.status(400).json({ error: 'invalid code — scan the QR and try the current 6-digit code' });
return;
}
save(deps, { ...req.customer!, totpEnabled: true });
res.json({ ok: true, totpEnabled: true });
});
router.post('/2fa/disable', (req, res) => {
const { code } = req.body ?? {};
const secret = req.customer!.totpSecret;
if (!secret || typeof code !== 'string' || !verifyTotp(secret, code, now())) {
res.status(400).json({ error: 'invalid code' });
return;
}
save(deps, { ...req.customer!, totpSecret: undefined, totpEnabled: false });
res.json({ ok: true, totpEnabled: false });
});
/* ---------------- billing ---------------- */
router.post('/reload', async (req, res) => {
const { amountCents } = req.body ?? {};
if (
typeof amountCents !== 'number' ||
!Number.isInteger(amountCents) ||
amountCents < MIN_RELOAD_CENTS ||
amountCents > MAX_RELOAD_CENTS
) {
res
.status(400)
.json({ error: `amountCents must be an integer between ${MIN_RELOAD_CENTS} and ${MAX_RELOAD_CENTS}` });
return;
}
const result = await deps.payments.reload(req.customer!, amountCents);
const customer = {
...req.customer!,
balanceCents: (req.customer!.balanceCents ?? 0) + result.creditedCents,
};
if (result.creditedCents > 0) save(deps, customer);
res.json({
balanceCents: customer.balanceCents,
mode: result.mode,
...(result.clientSecret ? { clientSecret: result.clientSecret } : {}),
});
});
router.put('/email-invoicing', (req, res) => {
const { enabled } = req.body ?? {};
if (typeof enabled !== 'boolean') {
res.status(400).json({ error: 'enabled must be a boolean' });
return;
}
save(deps, { ...req.customer!, emailInvoicing: enabled });
res.json({ ok: true, emailInvoicing: enabled });
});
return router;
}