verae-middleware/src/services/tenantService.js

137 lines
3.6 KiB
JavaScript

/**
* @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<object>}
*/
export function listProvisionedTenants() {
return listTenants().map((tenant) => ({
id: tenant.id,
name: tenant.name,
plan: tenant.plan,
audience: tenant.metadata?.audience ?? 'unknown',
createdAt: tenant.createdAt,
}));
}