Bind each customer to a Verae userId for hop tracing
Some checks are pending
offline / test (push) Waiting to run
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:
parent
1b199ca4d4
commit
345aeeead9
79 changed files with 703 additions and 95 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
76
packages/zappier/src/verae-bind.ts
Normal file
76
packages/zappier/src/verae-bind.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue