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
|
|
@ -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
|
||||
|
|
|
|||
16
packages/verae-zapier-middleware/src/lib/identity.js
Normal file
16
packages/verae-zapier-middleware/src/lib/identity.js
Normal 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)}`;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
20
packages/verae-zapier-middleware/src/store/tokenRefs.js
Normal file
20
packages/verae-zapier-middleware/src/store/tokenRefs.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue