Some checks are pending
offline / test (push) Waiting to run
Zapier is one ingress. Direct web, customer API, and S2S leaf nodes are their own services. Every hop to an internal subject must pass verae.access.authz.check (default deny by plane).
327 lines
10 KiB
TypeScript
327 lines
10 KiB
TypeScript
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';
|
|
import { CreditLedger } from './credits';
|
|
import { composeStatement } from './statement';
|
|
import { BILLING_SUBJECTS, natsPublish, natsStatement, natsAdjust } from './billing-nats';
|
|
|
|
/**
|
|
* 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;
|
|
credits?: CreditLedger;
|
|
}
|
|
|
|
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('/statement', async (req, res) => {
|
|
const fromNats = await natsStatement(req.customer!.id, 'web');
|
|
if (fromNats) {
|
|
res.json({ ...fromNats, source: 'nats' });
|
|
return;
|
|
}
|
|
res.json({
|
|
...composeStatement({
|
|
customerId: req.customer!.id,
|
|
prepaidCents: req.customer!.balanceCents ?? 0,
|
|
credits: (deps.credits || new CreditLedger()).list(req.customer!.id),
|
|
usage: deps.usage.listFor(req.customer!.id),
|
|
invoices: deps.invoices.list({ customerId: req.customer!.id }),
|
|
}),
|
|
source: 'local',
|
|
});
|
|
});
|
|
|
|
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);
|
|
if (result.creditedCents > 0) {
|
|
natsPublish(
|
|
BILLING_SUBJECTS.PAYMENT_RECORDED,
|
|
{
|
|
customerId: customer.id,
|
|
cents: result.creditedCents,
|
|
reason: 'reload',
|
|
},
|
|
'web',
|
|
);
|
|
void natsAdjust(
|
|
{
|
|
customerId: customer.id,
|
|
cents: result.creditedCents,
|
|
reason: 'reload',
|
|
agent: 'portal',
|
|
kind: 'reload',
|
|
},
|
|
'web',
|
|
);
|
|
}
|
|
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;
|
|
}
|