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

@ -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',