/** * @fileoverview Self-serve signup and admin tenant provisioning. * @module services/tenantService */ import { randomUUID } from 'node:crypto'; import { createTenant, getTenant, listTenants } from '../store/tenants.js'; import { veraeClient } from '../clients/veraeClient.js'; import { AppError } from '../errors.js'; import { createDebugger } from '../debug/logger.js'; const log = createDebugger('auth'); const ALLOWED_PLANS = new Set(['free', 'starter', 'pro', 'enterprise']); function slugify(value) { return value .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') .slice(0, 48); } async function validateVeraeCredentials(username, password) { try { await veraeClient.login({ username, password }); return true; } catch (err) { throw new AppError('Invalid Verae credentials', { status: 400, code: 'INVALID_VERAE_CREDENTIALS', details: { message: err.message }, }); } } /** * Public free-tier signup. * @param {{ email: string, name: string, veraeUsername: string, veraePassword: string }} params */ export async function selfServeSignup({ email, name, veraeUsername, veraePassword }) { if (!email || !name || !veraeUsername || !veraePassword) { throw new AppError('email, name, veraeUsername, and veraePassword are required', { status: 400, code: 'VALIDATION_ERROR', }); } await validateVeraeCredentials(veraeUsername, veraePassword); const id = `tenant-${slugify(email)}-${randomUUID().slice(0, 8)}`; const { tenant, apiKey } = createTenant({ id, name, plan: 'free', veraeUsername, veraePassword, contract: null, metadata: { email, audience: 'self-serve', createdVia: 'signup' }, }); log.info('self-serve signup', { tenantId: tenant.id }); return { tenant: { id: tenant.id, name: tenant.name, plan: tenant.plan, email }, apiKey, zapierSetup: { authType: 'custom', field: 'api_key', middlewareUrl: '/zapier/v1/auth/me', }, }; } /** * Admin provision (internal / enterprise). */ export async function provisionTenant({ id, name, plan, veraeUsername, veraePassword, contract = null, metadata = {}, audience = 'admin', }) { if (!name || !plan || !veraeUsername || !veraePassword) { throw new AppError('name, plan, veraeUsername, and veraePassword are required', { status: 400, code: 'VALIDATION_ERROR', }); } if (!ALLOWED_PLANS.has(plan)) { throw new AppError(`Invalid plan: ${plan}`, { status: 400, code: 'VALIDATION_ERROR' }); } if (plan === 'enterprise' && !contract) { throw new AppError('enterprise tenants require a contract object', { status: 400, code: 'VALIDATION_ERROR', }); } await validateVeraeCredentials(veraeUsername, veraePassword); const tenantId = id ?? `tenant-${slugify(name)}-${randomUUID().slice(0, 8)}`; if (getTenant(tenantId)) { throw new AppError('Tenant already exists', { status: 409, code: 'CONFLICT' }); } const { tenant, apiKey } = createTenant({ id: tenantId, name, plan, veraeUsername, veraePassword, 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 }; } /** * @returns {Array} */ export function listProvisionedTenants() { return listTenants().map((tenant) => ({ id: tenant.id, name: tenant.name, plan: tenant.plan, audience: tenant.metadata?.audience ?? 'unknown', createdAt: tenant.createdAt, })); }