Bind each customer to a Verae userId for hop tracing
Some checks are pending
offline / test (push) Waiting to run

Signup registers/binds a Verae central user and stores veraeUserId. Public access stays the zappier API key. Chain JWTs stay server-side behind tokenRef. Authz, billing, and jobs.watch carry veraeUserId.
This commit is contained in:
George Lambert 2026-09-11 16:18:06 -04:00
parent 1b199ca4d4
commit 345aeeead9
79 changed files with 703 additions and 95 deletions

View file

@ -248,10 +248,11 @@ export function adminRouter(
agent: typeof agent === 'string' ? agent : 'admin',
});
customers.save({ ...customer, balanceCents: (customer.balanceCents ?? 0) + delta });
natsPublish(BILLING_SUBJECTS.CREDIT_APPLIED, rec, 'staff');
natsPublish(BILLING_SUBJECTS.CREDIT_APPLIED, { ...rec, veraeUserId: customer.veraeUserId }, 'staff');
void natsAdjust(
{
customerId,
veraeUserId: customer.veraeUserId,
cents: delta,
reason: rec.reason,
agent: rec.agent,
@ -273,7 +274,7 @@ export function adminRouter(
res.status(404).json({ error: 'customer not found' });
return;
}
const fromNats = await natsStatement(customer.id, 'staff');
const fromNats = await natsStatement(customer.id, 'staff', customer.veraeUserId);
if (fromNats) {
res.json({ ...fromNats, name: customer.name, tierId: customer.tierId, source: 'nats' });
return;
@ -281,6 +282,7 @@ export function adminRouter(
res.json({
...composeStatement({
customerId: customer.id,
veraeUserId: customer.veraeUserId,
prepaidCents: customer.balanceCents ?? 0,
credits: credits.list(customer.id),
usage: accounting.usage.listFor(customer.id),

View file

@ -102,8 +102,14 @@ export function buildApp(deps: AppDeps = {}): {
};
const items: StoredItem[] = [];
const credits = new CreditLedger();
const onUsage = (e: { customerId: string; endpointId: string; cents: number }) =>
natsPublish(BILLING_SUBJECTS.USAGE_RECORDED, { ...e, at: new Date().toISOString() }, 'api');
const onUsage = (e: { customerId: string; endpointId: string; cents: number }) => {
const c = customers.list().find((row) => row.id === e.customerId);
natsPublish(
BILLING_SUBJECTS.USAGE_RECORDED,
{ ...e, veraeUserId: c?.veraeUserId, at: new Date().toISOString() },
'api',
);
};
const hashIndex = new Map<
string,
{ jobId: string; sha256: string; data?: string; timestamp: string }

View file

@ -12,6 +12,9 @@ export interface Customer {
/** How this customer is billed. Absent means 'stripe' (the default). */
billingType?: BillingType;
email?: string;
/** Stable Verae central user id (not a JWT). */
veraeUserId?: string;
veraeUsername?: string;
/** scrypt hash for portal login. Absent = no portal password set yet. */
passwordHash?: string;
/** Base32 TOTP secret. Present once 2FA setup begins. */

View file

@ -15,6 +15,7 @@ export type AccessPlane = 'zapier' | 'web' | 'api' | 'leaf' | 'staff';
export type BillingStatement = {
customerId: string;
veraeUserId?: string;
prepaidCents: number;
credits: unknown[];
usage: unknown[];
@ -54,7 +55,7 @@ function decode(buf: Uint8Array): unknown {
async function authzAllow(
plane: AccessPlane,
subject: string,
extra: { principal?: string; kind?: string } = {},
extra: { principal?: string; kind?: string; veraeUserId?: string } = {},
): Promise<boolean> {
const http = process.env.AUTHZ_URL;
if (!http && !(await nc())) return true;
@ -83,13 +84,18 @@ async function authzAllow(
export async function natsStatement(
customerId: string,
plane: AccessPlane = 'web',
veraeUserId?: string,
): Promise<BillingStatement | null> {
const c = await nc();
if (!c) return null;
if (!(await authzAllow(plane, BILLING_SUBJECTS.STATEMENT_GET, { principal: customerId }))) return null;
if (
!(await authzAllow(plane, BILLING_SUBJECTS.STATEMENT_GET, { principal: customerId, veraeUserId }))
) {
return null;
}
const m = await c.request(
BILLING_SUBJECTS.STATEMENT_GET,
encode({ customerId, plane }),
encode({ customerId, plane, veraeUserId }),
{ timeout: 2000 },
);
return decode(m.data) as BillingStatement;
@ -98,6 +104,7 @@ export async function natsStatement(
export async function natsAdjust(
payload: {
customerId: string;
veraeUserId?: string;
cents: number;
reason: string;
agent: string;
@ -111,6 +118,7 @@ export async function natsAdjust(
!(await authzAllow(plane, BILLING_SUBJECTS.BALANCE_ADJUST, {
principal: payload.agent,
kind: payload.kind,
veraeUserId: payload.veraeUserId,
}))
) {
return null;

View file

@ -17,6 +17,7 @@ import { UsageRepo } from './usage';
import { CreditLedger } from './credits';
import { composeStatement } from './statement';
import { BILLING_SUBJECTS, natsPublish, natsStatement, natsAdjust } from './billing-nats';
import { bindVeraeUser } from './verae-bind';
/**
* Customer portal API (/portal/api): signup, login with optional TOTP 2FA,
@ -63,6 +64,7 @@ function publicProfile(c: Customer) {
tierId: c.tierId,
billingType: c.billingType ?? 'stripe',
apiKey: c.apiKey,
veraeUserId: c.veraeUserId,
balanceCents: c.balanceCents ?? 0,
totpEnabled: c.totpEnabled ?? false,
emailInvoicing: c.emailInvoicing ?? false,
@ -97,7 +99,7 @@ export function portalRouter(deps: PortalDeps): Router {
/* ---------------- auth ---------------- */
router.post('/signup', (req, res) => {
router.post('/signup', async (req, res) => {
const { name, email, password } = req.body ?? {};
if (typeof name !== 'string' || name.trim().length === 0) {
res.status(400).json({ error: 'name is required' });
@ -133,6 +135,10 @@ export function portalRouter(deps: PortalDeps): Router {
balanceCents: 0,
};
}
if (!customer.veraeUserId) {
const bind = await bindVeraeUser(email);
customer = { ...customer, veraeUserId: bind.veraeUserId, veraeUsername: bind.veraeUsername };
}
save(deps, customer);
const session = deps.sessions.create(customer.id, ttl);
res.status(201).json({ token: session.token, customer: publicProfile(customer) });
@ -203,7 +209,7 @@ export function portalRouter(deps: PortalDeps): Router {
});
router.get('/statement', async (req, res) => {
const fromNats = await natsStatement(req.customer!.id, 'web');
const fromNats = await natsStatement(req.customer!.id, 'web', req.customer!.veraeUserId);
if (fromNats) {
res.json({ ...fromNats, source: 'nats' });
return;
@ -211,6 +217,7 @@ export function portalRouter(deps: PortalDeps): Router {
res.json({
...composeStatement({
customerId: req.customer!.id,
veraeUserId: req.customer!.veraeUserId,
prepaidCents: req.customer!.balanceCents ?? 0,
credits: (deps.credits || new CreditLedger()).list(req.customer!.id),
usage: deps.usage.listFor(req.customer!.id),
@ -290,6 +297,7 @@ export function portalRouter(deps: PortalDeps): Router {
BILLING_SUBJECTS.PAYMENT_RECORDED,
{
customerId: customer.id,
veraeUserId: customer.veraeUserId,
cents: result.creditedCents,
reason: 'reload',
},
@ -298,6 +306,7 @@ export function portalRouter(deps: PortalDeps): Router {
void natsAdjust(
{
customerId: customer.id,
veraeUserId: customer.veraeUserId,
cents: result.creditedCents,
reason: 'reload',
agent: 'portal',

View file

@ -4,6 +4,7 @@ import { UsageEntry } from './usage';
export function composeStatement(args: {
customerId: string;
veraeUserId?: string;
prepaidCents: number;
credits: CreditAdjustment[];
usage: UsageEntry[];
@ -21,6 +22,7 @@ export function composeStatement(args: {
}));
return {
customerId: args.customerId,
veraeUserId: args.veraeUserId,
prepaidCents: args.prepaidCents,
credits: args.credits.filter((c) => c.customerId === args.customerId),
usage: args.usage

View file

@ -23,6 +23,8 @@ export async function proxyVerae(
if (key) headers['x-api-key'] = key;
const auth = req.header('authorization');
if (auth) headers.authorization = auth;
const veraeUserId = req.customer?.veraeUserId;
if (veraeUserId) headers['x-verae-user-id'] = veraeUserId;
const method = req.method.toUpperCase();
const init: RequestInit = { method, headers };
if (method !== 'GET' && method !== 'HEAD') {

View file

@ -0,0 +1,76 @@
/**
* Bind a portal customer to a Verae central user id.
* The public credential stays the zappier API key. The Verae JWT never leaves the server.
*/
import { createHash, randomBytes } from 'crypto';
export function normalizeVeraeUsername(username: string): string {
return username.trim().toLowerCase();
}
export function stableVeraeUserId(username: string): string {
const n = normalizeVeraeUsername(username);
return `vu_${createHash('sha256').update(n).digest('hex').slice(0, 16)}`;
}
export type VeraeBind = {
veraeUserId: string;
veraeUsername: string;
bound: boolean;
};
function mockBind(email: string): VeraeBind {
const veraeUsername = normalizeVeraeUsername(email);
return { veraeUserId: stableVeraeUserId(veraeUsername), veraeUsername, bound: true };
}
/**
* Register or look up the customer on api.veraetime.net.
* MOCK_VERAE (default) or missing VERAE_API_BASE_URL stable id, no network.
*/
export async function bindVeraeUser(email: string): Promise<VeraeBind> {
const mock = process.env.MOCK_VERAE !== 'false';
const base = (process.env.VERAE_API_BASE_URL || '').replace(/\/$/, '');
if (mock || !base) return mockBind(email);
const veraeUsername = normalizeVeraeUsername(email);
const adminUser = process.env.VERAE_ADMIN_USER;
const adminPass = process.env.VERAE_ADMIN_PASSWORD;
const password = `vt_${randomBytes(18).toString('base64url')}`;
const login = async (username: string, pass: string) => {
const r = await fetch(`${base}/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password: pass }),
});
if (!r.ok) throw new Error(`verae login ${r.status}`);
return r.json() as Promise<{ token: string; user?: { id?: string; username?: string } }>;
};
if (!adminUser || !adminPass) return mockBind(email);
try {
const admin = await login(adminUser, adminPass);
const created = await fetch(`${base}/auth/users`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${admin.token}`,
},
body: JSON.stringify({ username: veraeUsername, password, role: 'user' }),
});
if (!created.ok && created.status !== 409) {
return mockBind(email);
}
const user = (await created.json().catch(() => ({}))) as { id?: string; username?: string };
const id = user.id || (await login(veraeUsername, password)).user?.id;
return {
veraeUserId: id || stableVeraeUserId(veraeUsername),
veraeUsername,
bound: true,
};
} catch {
return mockBind(email);
}
}

View file

@ -19,7 +19,7 @@ async function signup(app: ReturnType<typeof buildApp>['app'], email = 'ada@exam
.post('/portal/api/signup')
.send({ name: 'Ada', email, password: 'super-secret-1' });
expect(res.status).toBe(201);
return res.body as { token: string; customer: { id: string; apiKey: string } };
return res.body as { token: string; customer: { id: string; apiKey: string; veraeUserId?: string } };
}
describe('portal signup + login', () => {
@ -34,6 +34,9 @@ describe('portal signup + login', () => {
expect(me.status).toBe(200);
expect(me.body.tierId).toBe('free');
expect(me.body.balanceCents).toBe(0);
expect(customer.veraeUserId).toMatch(/^vu_[0-9a-f]{16}$/);
expect(me.body.veraeUserId).toBe(customer.veraeUserId);
expect(JSON.stringify(me.body)).not.toMatch(/eyJ|mock-jwt|veraeToken/);
});
it('never leaks passwordHash or totpSecret through the API', async () => {