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:
commit
b4150c8250
1364 changed files with 6814366 additions and 0 deletions
284
packages/zappier/src/portal.ts
Normal file
284
packages/zappier/src/portal.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue