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

@ -59,7 +59,7 @@ Zapier cloud **never** connects to NATS. Internal services do: middleware worker
| Address | Kind | Payload (required fields) |
|---------|------|---------------------------|
| `verae.zapier.jobs.watch` | JetStream work queue | `tenantId`, `jobId`, `tokenRef`, `enqueuedAt`, `attempt`, `maxAttempts`, `intervalMs`, `traceId` |
| `verae.zapier.jobs.watch` | JetStream work queue | `tenantId`, `veraeUserId`, `jobId`, `tokenRef`, `enqueuedAt`, `attempt`, `maxAttempts`, `intervalMs`, `traceId` |
| `verae.zapier.jobs.events` | JetStream events | `event` (`timestamp.completed\|failed\|timeout`), `tenantId`, `jobId`, `status`, `traceId`, `emittedAt` |
| `verae.zapier.webhooks.deliver` | JetStream work queue | `hookId`, `tenantId`, `targetUrl`, `event`, `payload`, `attempt`, `traceId` |
| `verae.zapier.usage` | optional | `tenantId`, `action`, `amount`, `at` |

View file

@ -6,7 +6,7 @@ Zapier cloud still never connects to NATS. The edge process **does** publish and
|-----------|---------|------|------|
| IN HTTPS | `/v1/*` | Zapier Platform app | JSON + `x-api-key` |
| OUT HTTPS | middleware `/zapier/v1/*` | verae-middleware | same tenant request |
| OUT NATS | `verae.billing.usage.recorded` | account-balance | meter event |
| OUT NATS | `verae.billing.usage.recorded` | account-balance | meter event (`customerId`, `veraeUserId`) |
| OUT NATS | `verae.billing.payment.recorded` | account-balance / others | portal reload |
| OUT NATS | `verae.billing.credit.applied` | account-balance / others | admin/CS credit |
| OUT NATS | `verae.billing.statement.get` | account-balance | portal/admin review |

View file

@ -89,6 +89,8 @@ export function authorize(req) {
plane,
subject: internal,
principal: req?.principal || null,
veraeUserId: req?.veraeUserId || req?.payload?.veraeUserId || null,
traceId: req?.traceId || req?.payload?.traceId || null,
reason: 'ok',
};
}

View file

@ -60,6 +60,19 @@ test('access-prefixed address is mapped to internal', () => {
assert.equal(authorize({ plane: 'web', subject: addr }).allow, false);
});
test('allow echoes veraeUserId for hop tracing', () => {
const out = authorize({
plane: 'web',
subject: 'verae.billing.statement.get',
principal: 'cust_1',
veraeUserId: 'vu_deadbeefdeadbeef',
traceId: 'tr-1',
});
assert.equal(out.allow, true);
assert.equal(out.veraeUserId, 'vu_deadbeefdeadbeef');
assert.equal(out.traceId, 'tr-1');
});
test('unknown plane and authz subjects denied', () => {
assert.equal(authorize({ plane: 'partner', subject: 'verae.archive.put' }).allow, false);
assert.equal(authorize({ plane: 'web', subject: 'verae.access.authz.check' }).allow, false);

View file

@ -7,6 +7,7 @@ import { createHash, randomUUID } from 'node:crypto';
import { config } from '../config.js';
import { AppError } from '../errors.js';
import { createDebugger } from '../debug/logger.js';
import { stableVeraeUserId } from '../lib/identity.js';
const log = createDebugger('http');
@ -39,7 +40,7 @@ async function mockLogin({ username, password }) {
token: `mock-jwt-${username}`,
expiresAt,
user: {
id: randomUUID(),
id: stableVeraeUserId(username),
username,
role: username.includes('admin') ? 'admin' : 'user',
},
@ -53,13 +54,24 @@ async function mockValidate(token) {
const username = token.replace('mock-jwt-', '');
return {
valid: true,
userId: randomUUID(),
userId: stableVeraeUserId(username),
username,
role: 'user',
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
};
}
async function mockCreateUser({ username, password, role = 'user' }) {
if (!username || !password) {
throw new AppError('Invalid input', { status: 400, code: 'VALIDATION_ERROR' });
}
return {
id: stableVeraeUserId(username),
username,
role,
};
}
async function mockCreateTimestamp({ data, hashAlg, sha256, publicMetadata, privateMetadata }) {
if (!data && !sha256) {
throw new AppError('Invalid input data', { status: 400, code: 'VALIDATION_ERROR' });
@ -228,6 +240,28 @@ export const veraeClient = {
return request('/auth/validate', { token });
},
/**
* Admin-only Verae user register. JWT is not returned to callers of bind.
* @param {string} adminToken
* @param {{ username: string, password: string, role?: string }} body
*/
async createUser(adminToken, body) {
if (config.mockVerae) return mockCreateUser(body);
return request('/auth/users', { method: 'POST', token: adminToken, body });
},
/**
* Login (or create via admin) and return the stable user id never the JWT.
* @param {{ username: string, password: string }} credentials
*/
async bindUser(credentials) {
const login = await this.login(credentials);
return {
veraeUserId: login.user?.id || stableVeraeUserId(credentials.username),
veraeUsername: login.user?.username || credentials.username,
};
},
/**
* @param {string} token
* @param {{ data: string, hashAlg?: string }} body

View file

@ -0,0 +1,16 @@
/**
* Stable Verae user id derived from username/email.
* Live /auth/login.user.id wins when present; this is the mock/offline id.
*/
import { createHash } from 'node:crypto';
export function normalizeVeraeUsername(username) {
return String(username || '')
.trim()
.toLowerCase();
}
export function stableVeraeUserId(username) {
const n = normalizeVeraeUsername(username);
return `vu_${createHash('sha256').update(n).digest('hex').slice(0, 16)}`;
}

View file

@ -18,8 +18,14 @@ export const authenticate = asyncHandler(async (req, res, next) => {
extractBearerToken(req.headers.authorization) ?? req.headers['x-api-key'] ?? null;
req.auth = await resolveAuthContext(rawToken);
const headerId = req.headers['x-verae-user-id'];
if (typeof headerId === 'string' && headerId.startsWith('vu_') && !req.auth.veraeUserId) {
req.auth.veraeUserId = headerId;
if (req.auth.tenant) req.auth.tenant.veraeUserId = headerId;
}
log.debug('authenticated', {
tenantId: req.auth.tenantId,
veraeUserId: req.auth.veraeUserId,
method: req.auth.authMethod,
plan: req.auth.tenant?.plan,
});

View file

@ -37,9 +37,11 @@ export async function enqueueWatch(partial) {
log.debug('enqueueWatch', {
subject: SUBJECTS.JOBS_WATCH,
tenantId: msg.tenantId,
veraeUserId: msg.veraeUserId,
jobId: msg.jobId,
attempt: msg.attempt,
traceId: msg.traceId,
hasTokenRef: Boolean(msg.tokenRef),
});
const js = await requireJs();

View file

@ -4,7 +4,7 @@
*/
import { veraeClient } from '../clients/veraeClient.js';
import { getTenantByApiKey, getTenant } from '../store/tenants.js';
import { getTenantByApiKey, getTenant, upsertTenant } from '../store/tenants.js';
import { issueSessionToken, parseSessionToken, isApiKey } from '../lib/tokens.js';
import { AppError } from '../errors.js';
import { createDebugger } from '../debug/logger.js';
@ -33,6 +33,10 @@ export async function loginWithCredentials({ username, password, tenant }) {
}
const tenantId = tenant?.id ?? `user:${verae.user.username}`;
const veraeUserId = verae.user?.id;
if (tenant && veraeUserId && tenant.veraeUserId !== veraeUserId) {
upsertTenant({ ...tenant, veraeUserId });
}
const accessToken = issueSessionToken({
tenantId,
veraeToken: verae.token,
@ -43,9 +47,10 @@ export async function loginWithCredentials({ username, password, tenant }) {
accessToken,
expiresAt: verae.expiresAt,
tenant: tenant
? { id: tenant.id, name: tenant.name, plan: tenant.plan }
: { id: tenantId, name: verae.user.username, plan: 'free' },
? { id: tenant.id, name: tenant.name, plan: tenant.plan, veraeUserId: veraeUserId || tenant.veraeUserId }
: { id: tenantId, name: verae.user.username, plan: 'free', veraeUserId },
user: verae.user,
veraeUserId,
};
}
@ -93,8 +98,14 @@ export async function resolveAuthContext(rawToken) {
const tenant = getTenant(session.tenant.id) ?? session.tenant;
return {
tenantId: session.tenant.id,
tenant: { id: session.tenant.id, name: session.tenant.name, plan: session.tenant.plan },
tenant: {
id: session.tenant.id,
name: session.tenant.name,
plan: session.tenant.plan,
veraeUserId: session.veraeUserId || tenant?.veraeUserId,
},
veraeToken: parsed.veraeToken,
veraeUserId: session.veraeUserId || tenant?.veraeUserId,
authMethod: 'api_key',
fullTenant: tenant,
};
@ -114,9 +125,10 @@ export async function resolveAuthContext(rawToken) {
return {
tenantId: parsed.tenantId,
tenant: tenant
? { id: tenant.id, name: tenant.name, plan: tenant.plan }
? { id: tenant.id, name: tenant.name, plan: tenant.plan, veraeUserId: tenant.veraeUserId }
: { id: parsed.tenantId, plan: 'free' },
veraeToken: parsed.veraeToken,
veraeUserId: tenant?.veraeUserId,
authMethod: 'session',
fullTenant: tenant,
};

View file

@ -45,7 +45,7 @@ export async function selfServeSignup({ email, name, veraeUsername, veraePasswor
});
}
await validateVeraeCredentials(veraeUsername, veraePassword);
const bound = await veraeClient.bindUser({ username: veraeUsername, password: veraePassword });
const id = `tenant-${slugify(email)}-${randomUUID().slice(0, 8)}`;
const { tenant, apiKey } = createTenant({
@ -54,14 +54,21 @@ export async function selfServeSignup({ email, name, veraeUsername, veraePasswor
plan: 'free',
veraeUsername,
veraePassword,
veraeUserId: bound.veraeUserId,
contract: null,
metadata: { email, audience: 'self-serve', createdVia: 'signup' },
});
log.info('self-serve signup', { tenantId: tenant.id });
log.info('self-serve signup', { tenantId: tenant.id, veraeUserId: bound.veraeUserId });
return {
tenant: { id: tenant.id, name: tenant.name, plan: tenant.plan, email },
tenant: {
id: tenant.id,
name: tenant.name,
plan: tenant.plan,
email,
veraeUserId: bound.veraeUserId,
},
apiKey,
zapierSetup: {
authType: 'custom',
@ -102,7 +109,7 @@ export async function provisionTenant({
});
}
await validateVeraeCredentials(veraeUsername, veraePassword);
const bound = await veraeClient.bindUser({ username: veraeUsername, password: veraePassword });
const tenantId = id ?? `tenant-${slugify(name)}-${randomUUID().slice(0, 8)}`;
if (getTenant(tenantId)) {
@ -115,12 +122,16 @@ export async function provisionTenant({
plan,
veraeUsername,
veraePassword,
veraeUserId: bound.veraeUserId,
contract,
metadata: { ...metadata, audience, createdVia: 'provision' },
});
log.info('tenant provisioned', { tenantId: tenant.id, plan, audience });
return { tenant: { id: tenant.id, name: tenant.name, plan: tenant.plan }, apiKey };
log.info('tenant provisioned', { tenantId: tenant.id, plan, audience, veraeUserId: bound.veraeUserId });
return {
tenant: { id: tenant.id, name: tenant.name, plan: tenant.plan, veraeUserId: bound.veraeUserId },
apiKey,
};
}
/**
@ -132,6 +143,7 @@ export function listProvisionedTenants() {
name: tenant.name,
plan: tenant.plan,
audience: tenant.metadata?.audience ?? 'unknown',
veraeUserId: tenant.veraeUserId,
createdAt: tenant.createdAt,
}));
}

View file

@ -6,6 +6,7 @@
import { config } from '../config.js';
import { veraeClient } from '../clients/veraeClient.js';
import { enqueueJob } from '../store/jobWatchers.js';
import { issueTokenRef } from '../store/tokenRefs.js';
import { checkEntitlement, recordUsage } from './entitlementService.js';
import { createDebugger } from '../debug/logger.js';
import { getTraceId } from '../debug/trace-context.js';
@ -25,8 +26,8 @@ async function enqueueWatchForJob(ctx, jobId) {
await enqueueWatch({
tenantId: ctx.tenantId,
jobId,
// Prefer re-login in worker; include token for MVP simplicity when mock
veraeToken: ctx.veraeToken,
tokenRef: issueTokenRef(ctx.tenantId),
veraeUserId: ctx.veraeUserId || ctx.tenant?.veraeUserId,
maxAttempts: config.jobPollMaxAttempts,
intervalMs: config.jobPollIntervalMs,
traceId,

View file

@ -33,6 +33,7 @@ export function emptyStore() {
usage: {},
webhooks: [],
jobWatchers: [],
tokenRefs: {},
blobs: {},
shares: {},
trees: {},
@ -59,6 +60,7 @@ export function loadStore(path = config.storePath) {
usage: parsed.usage ?? {},
webhooks: Array.isArray(parsed.webhooks) ? parsed.webhooks : [],
jobWatchers: Array.isArray(parsed.jobWatchers) ? parsed.jobWatchers : [],
tokenRefs: parsed.tokenRefs ?? {},
};
log.debug('store loaded', { path, tenants: Object.keys(store.tenants).length });
} catch (err) {

View file

@ -17,6 +17,7 @@ const log = createDebugger('billing');
* @property {string} plan
* @property {string} veraeUsername
* @property {string} veraePassword
* @property {string} [veraeUserId]
* @property {object|null} contract
* @property {object} [metadata]
* @property {string} createdAt
@ -68,6 +69,7 @@ export function upsertTenant(tenant) {
* @param {string} [params.plan='free']
* @param {string} params.veraeUsername
* @param {string} params.veraePassword
* @param {string} [params.veraeUserId]
* @param {object|null} [params.contract=null]
* @param {string} [params.apiKey]
* @param {object} [params.metadata]
@ -79,6 +81,7 @@ export function createTenant({
plan = 'free',
veraeUsername,
veraePassword,
veraeUserId,
contract = null,
apiKey = generateApiKey(),
metadata = {},
@ -91,6 +94,7 @@ export function createTenant({
plan,
veraeUsername,
veraePassword,
veraeUserId,
contract,
metadata,
createdAt: new Date().toISOString(),

View file

@ -0,0 +1,20 @@
/**
* Opaque tokenRef tenantId. Workers resolve a fresh Verae JWT; NATS never carries it.
*/
import { randomBytes } from 'node:crypto';
import { getStore, persist } from './db.js';
export function issueTokenRef(tenantId) {
const ref = `tref_${randomBytes(12).toString('hex')}`;
const store = getStore();
if (!store.tokenRefs) store.tokenRefs = {};
store.tokenRefs[ref] = { tenantId, createdAt: new Date().toISOString() };
persist();
return ref;
}
export function resolveTokenRef(ref) {
if (!ref) return null;
const row = getStore().tokenRefs?.[ref];
return row?.tenantId ?? null;
}

View file

@ -10,6 +10,7 @@ import { connectNats, ensureStreams } from '../nats/connection.js';
import { publishJobEvent } from '../nats/publishers.js';
import { veraeClient } from '../clients/veraeClient.js';
import { getTenant } from '../store/tenants.js';
import { resolveTokenRef } from '../store/tokenRefs.js';
import { withTrace } from '../debug/trace.js';
const log = createDebugger('jobs');
@ -26,9 +27,10 @@ let abort = null;
async function resolveVeraeToken(msg) {
if (msg.veraeToken) return msg.veraeToken;
const tenant = getTenant(msg.tenantId);
const tenantId = msg.tokenRef ? resolveTokenRef(msg.tokenRef) || msg.tenantId : msg.tenantId;
const tenant = getTenant(tenantId);
if (!tenant?.veraeUsername) {
throw new Error(`Cannot resolve token for tenant ${msg.tenantId}`);
throw new Error(`Cannot resolve token for tenant ${tenantId}`);
}
const login = await veraeClient.login({
username: tenant.veraeUsername,

View file

@ -44,6 +44,8 @@ describe('tenancy', () => {
const body = await res.json();
assert.equal(body.tenant.plan, 'free');
assert.ok(body.apiKey.startsWith('zmw_'));
assert.match(body.tenant.veraeUserId, /^vu_[0-9a-f]{16}$/);
assert.doesNotMatch(JSON.stringify(body), /mock-jwt|eyJhbGci/);
});
it('enterprise without contract rejected', async () => {

View file

@ -0,0 +1,27 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { stableVeraeUserId } from '../../src/lib/identity.js';
import { issueTokenRef, resolveTokenRef } from '../../src/store/tokenRefs.js';
import { useTempStore } from '../helpers.js';
describe('verae identity', () => {
it('stableVeraeUserId is deterministic and not a JWT', () => {
const a = stableVeraeUserId('Ada@Example.com');
const b = stableVeraeUserId('ada@example.com');
assert.equal(a, b);
assert.match(a, /^vu_[0-9a-f]{16}$/);
assert.doesNotMatch(a, /eyJ/);
});
it('tokenRef resolves tenant and is not a Verae JWT', () => {
const ctx = useTempStore();
try {
const ref = issueTokenRef('tenant-1');
assert.match(ref, /^tref_/);
assert.equal(resolveTokenRef(ref), 'tenant-1');
assert.doesNotMatch(ref, /mock-jwt|eyJ/);
} finally {
ctx.cleanup();
}
});
});

View file

@ -18,6 +18,9 @@ describe('veraeClient mock', () => {
it('create → wait → completed', async () => {
const login = await veraeClient.login({ username: 'u', password: 'p' });
assert.ok(login.token.startsWith('mock-jwt-'));
const again = await veraeClient.login({ username: 'u', password: 'p' });
assert.equal(login.user.id, again.user.id);
assert.match(login.user.id, /^vu_[0-9a-f]{16}$/);
const { jobId } = await veraeClient.createTimestamp(login.token, {
data: 'hello',

View file

@ -7,6 +7,7 @@ You need an API key **before** you can connect Zapier.
3. Enter name, email, and a password of at least 8 characters.
4. You start on the **Free** plan and receive an **API key immediately**.
5. Copy the key. It is sent as `x-api-key` on every Zapier request.
6. Signup also **registers you on Verae central** and stores a stable `veraeUserId` on your account. That id rides every internal hop (billing, authz, job watch) so CS/sales and the chain can see the same person. You never paste the Verae JWT into Zapier — that token stays on the server (`tokenRef` on NATS).
If your company already created an account (you received a key by email), sign up with the **same email**. The existing plan and key are kept; your password is attached to that account.

View file

@ -4,6 +4,7 @@
- NS1 `nats-server` stays on **127.0.0.1:4222**. Operators use `scripts/nats-tunnel.sh`; it is not a public bind.
- File bytes and private metadata never go on chain. Private metadata is only on authenticated archive replies.
- API keys are `x-api-key` / Bearer tokens on HTTPS. Treat them like passwords; regenerating kills old Zaps.
- The Verae central JWT from `/auth/login` is **not** your Zapier/portal token. Middleware holds it (or re-logins via `tokenRef`). `veraeUserId` is the public correlation id (`vu_…`).
- Bloom filters are **not** an access-control list. On a hit, middleware still checks tenant/share before returning private records.
If a trace (simulator or `DEBUG_VERAE`) ever shows a `zapier-platform-app` hop with a `verae.*` subject, that is a bug — do not push the app.

View file

@ -5,16 +5,23 @@ export class AccountBooks {
constructor() {
/** @type {Record<string, number>} */
this.prepaid = {};
/** @type {Record<string, string>} */
this.veraeUserIds = {};
this.credits = [];
this.usage = [];
this.payments = [];
}
remember(customerId, veraeUserId) {
if (customerId && veraeUserId) this.veraeUserIds[customerId] = veraeUserId;
}
prepaidCents(customerId) {
return this.prepaid[customerId] ?? 0;
}
adjust({ customerId, cents, reason, agent, kind = 'credit' }) {
adjust({ customerId, cents, reason, agent, kind = 'credit', veraeUserId }) {
this.remember(customerId, veraeUserId);
const delta = Math.trunc(Number(cents) || 0);
const next = this.prepaidCents(customerId) + delta;
this.prepaid[customerId] = next;
@ -34,6 +41,7 @@ export class AccountBooks {
}
recordUsage(entry) {
this.remember(entry.customerId, entry.veraeUserId);
const cents = Number(entry.cents) || 0;
const customerId = entry.customerId;
const next = this.prepaidCents(customerId) - cents;
@ -63,6 +71,7 @@ export class AccountBooks {
const match = (rows) => rows.filter((r) => r.customerId === customerId).slice(0, 100);
return {
customerId,
veraeUserId: this.veraeUserIds[customerId],
prepaidCents: this.prepaidCents(customerId),
credits: match(this.credits),
usage: match(this.usage),
@ -74,6 +83,7 @@ export class AccountBooks {
export function handle(subject, payload, books) {
const p = payload || {};
if (subject.endsWith('balance.get') || subject.endsWith('statement.get')) {
books.remember(p.customerId, p.veraeUserId);
return books.statement(p.customerId);
}
if (subject.endsWith('balance.adjust') || subject.endsWith('credit.applied')) {

View file

@ -5,11 +5,16 @@ import { SUBJECTS } from '../src/subjects.js';
test('adjust credits prepaid and statement lists credits usage payments', () => {
const books = new AccountBooks();
handle(SUBJECTS.BALANCE_ADJUST, { customerId: 'c1', cents: 500, reason: 'goodwill', agent: 'cs' }, books);
handle(
SUBJECTS.BALANCE_ADJUST,
{ customerId: 'c1', veraeUserId: 'vu_abc', cents: 500, reason: 'goodwill', agent: 'cs' },
books,
);
handle(SUBJECTS.USAGE_RECORDED, { customerId: 'c1', endpointId: 'timestamp', cents: 4 }, books);
handle(SUBJECTS.PAYMENT_RECORDED, { customerId: 'c1', cents: 1000, reason: 'reload' }, books);
const st = handle(SUBJECTS.STATEMENT_GET, { customerId: 'c1' }, books);
assert.equal(st.prepaidCents, 1496);
assert.equal(st.veraeUserId, 'vu_abc');
assert.equal(st.credits[0].cents, 500);
assert.equal(st.usage[0].endpointId, 'timestamp');
assert.equal(st.payments[0].kind, 'payment');

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 () => {