diff --git a/docs/tenants-api.md b/docs/tenants-api.md new file mode 100644 index 00000000..0398f011 --- /dev/null +++ b/docs/tenants-api.md @@ -0,0 +1,85 @@ +# /api/tenants + +Tenant write endpoints are authenticated and validate request input with Zod +before handlers run. Validation failures return the standard error envelope. + +## POST /api/tenants + +Creates a tenant record for the authenticated actor. + +Required header: + +```http +x-user-id: dev-1 +``` + +Request body: + +```json +{ + "name": "GrantFox Ops", + "slug": "grantfox-ops", + "contactEmail": "ops@grantfox.test", + "plan": "growth", + "metadata": { + "campaign": "fwc26" + } +} +``` + +Fields: + +| Field | Required | Notes | +|---|---:|---| +| `name` | yes | Trimmed string, 1-120 chars | +| `slug` | no | 3-63 lowercase letters, numbers, or hyphens; normalized to lowercase | +| `contactEmail` | no | Valid email address, max 254 chars | +| `plan` | no | `starter`, `growth`, or `enterprise`; defaults to `starter` | +| `metadata` | no | Up to 20 keys; primitive string/number/boolean values only | + +Success response: `201` with `{ success: true, data, requestId, timestamp }`. + +## PATCH /api/tenants/:tenantId + +Updates a tenant. `tenantId` must be 3-64 chars using letters, numbers, +underscores, or hyphens. + +Request body accepts at least one of: + +```json +{ + "name": "GrantFox Stadium Ops", + "contactEmail": "stadium-ops@grantfox.test", + "plan": "enterprise", + "metadata": { + "campaign": "fwc26" + } +} +``` + +Success response: `200` with `{ success: true, data, requestId, timestamp }`. + +## Validation Errors + +Invalid requests return `400` before route logic runs: + +```json +{ + "success": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "Request validation failed", + "details": [ + { + "field": "body.name", + "message": "name is required", + "code": "INVALID_TYPE" + } + ] + }, + "requestId": "req-tenant-create", + "timestamp": "2026-07-28T00:00:00.000Z" +} +``` + +Unknown JSON fields are rejected. diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index 18d614ac..0afe6a70 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -5,7 +5,6 @@ import type { ValidationErrorDetail } from './validate.js'; import { ValidationError } from './validate.js'; import { buildErrorEnvelope } from './envelope.js'; import type { ErrorEnvelope } from '../types/ResponseEnvelope.js'; -import { buildErrorEnvelope } from './envelope.js'; const isProduction = process.env.NODE_ENV === "production"; @@ -100,7 +99,7 @@ export function errorHandler( } const details = extractValidationDetails(err); - const body = errorEnvelope(code, finalMessage, requestId, details); + const body = buildErrorEnvelope(code, finalMessage, requestId, details); if (!res.headersSent) { res.status(statusCode).json(body); diff --git a/src/routes/index.ts b/src/routes/index.ts index 79a16882..f8bca5d4 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -25,6 +25,7 @@ import type { SubscriptionRepository } from "../repositories/subscriptionReposit import type { DeveloperRepository } from "../repositories/developerRepository.js"; import type { ApiRepository } from "../repositories/apiRepository.js"; import { createForecastRouter } from "./forecast.js"; +import { createTenantsRouter } from "./tenants.js"; import { config } from "../config/index.js"; import { createBillingRateLimitMiddleware } from "../middleware/rateLimit.js"; @@ -88,6 +89,7 @@ export function createApiRouter(deps: ApiRouterDeps = {}): Router { ); router.use("/forecast", createForecastRouter()); + router.use("/tenants", createTenantsRouter()); if (deps.scheduledExportsService) { router.use( diff --git a/src/routes/tenants.test.ts b/src/routes/tenants.test.ts new file mode 100644 index 00000000..ef3ccd25 --- /dev/null +++ b/src/routes/tenants.test.ts @@ -0,0 +1,267 @@ +import express from 'express'; +import request from 'supertest'; +import { errorHandler } from '../middleware/errorHandler.js'; +import { requestIdMiddleware } from '../middleware/requestId.js'; +import { logger } from '../logger.js'; +import { + createTenantsRouter, + type TenantRecord, + type TenantRepository, +} from './tenants.js'; +import type { CreateTenantInput, UpdateTenantInput } from '../validators/tenants.js'; + +class MockTenantRepository implements TenantRepository { + create = jest.fn(async (input: CreateTenantInput, actorId: string): Promise => ({ + id: 'ten_test_123', + name: input.name, + slug: input.slug ?? 'grantfox-ops', + contactEmail: input.contactEmail, + plan: input.plan, + metadata: input.metadata, + createdBy: actorId, + createdAt: '2026-07-28T00:00:00.000Z', + updatedAt: '2026-07-28T00:00:00.000Z', + })); + + update = jest.fn(async (tenantId: string, input: UpdateTenantInput, actorId: string): Promise => ({ + id: tenantId, + name: input.name ?? 'GrantFox Ops', + slug: 'grantfox-ops', + contactEmail: input.contactEmail, + plan: input.plan ?? 'starter', + metadata: input.metadata, + createdBy: actorId, + createdAt: '2026-07-28T00:00:00.000Z', + updatedAt: '2026-07-28T01:00:00.000Z', + })); +} + +function buildApp(repository = new MockTenantRepository()) { + const app = express(); + app.use(express.json()); + app.use(requestIdMiddleware); + app.use('/api/tenants', createTenantsRouter({ tenantRepository: repository })); + app.use(errorHandler); + return { app, repository }; +} + +function buildDefaultRepositoryApp() { + const app = express(); + app.use(express.json()); + app.use(requestIdMiddleware); + app.use('/api/tenants', createTenantsRouter()); + app.use(errorHandler); + return app; +} + +describe('createTenantsRouter', () => { + let infoSpy: jest.SpyInstance; + + beforeEach(() => { + infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined); + }); + + afterEach(() => { + infoSpy.mockRestore(); + }); + + it('returns 401 before validation when unauthenticated', async () => { + const { app, repository } = buildApp(); + + const res = await request(app).post('/api/tenants').send({}); + + expect(res.status).toBe(401); + expect(res.body.error.code).toBe('UNAUTHORIZED'); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it('creates a tenant with parsed input and structured success envelope', async () => { + const { app, repository } = buildApp(); + + const res = await request(app) + .post('/api/tenants') + .set('x-user-id', 'dev-1') + .set('x-request-id', 'req-tenant-create') + .set('x-correlation-id', 'corr-tenant-create') + .send({ + name: ' GrantFox Ops ', + slug: 'GrantFox-Ops', + contactEmail: 'ops@grantfox.test', + plan: 'growth', + metadata: { campaign: 'fwc26' }, + }); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ + success: true, + data: { + id: 'ten_test_123', + name: 'GrantFox Ops', + slug: 'grantfox-ops', + plan: 'growth', + }, + requestId: 'req-tenant-create', + }); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ name: 'GrantFox Ops', slug: 'grantfox-ops' }), + 'dev-1', + ); + expect(infoSpy).toHaveBeenCalledWith( + '[tenants] tenant created', + expect.objectContaining({ + requestId: 'req-tenant-create', + correlationId: 'corr-tenant-create', + tenantId: 'ten_test_123', + actorId: 'dev-1', + }), + ); + }); + + it('returns structured 400 for invalid create body', async () => { + const { app, repository } = buildApp(); + + const res = await request(app) + .post('/api/tenants') + .set('x-user-id', 'dev-1') + .set('x-request-id', 'req-invalid-create') + .send({ contactEmail: 'bad-email' }); + + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'Request validation failed', + }, + requestId: 'req-invalid-create', + }); + expect(res.body.error.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: 'body.name' }), + expect.objectContaining({ field: 'body.contactEmail' }), + ]), + ); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it('returns structured 400 for unknown create fields', async () => { + const { app } = buildApp(); + + const res = await request(app) + .post('/api/tenants') + .set('x-user-id', 'dev-1') + .send({ name: 'GrantFox Ops', unsafeRole: 'admin' }); + + expect(res.status).toBe(400); + expect(res.body.error.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: 'body', code: 'UNRECOGNIZED_KEYS' }), + ]), + ); + }); + + it('updates a tenant with validated params and body', async () => { + const { app, repository } = buildApp(); + + const res = await request(app) + .patch('/api/tenants/tenant_123') + .set('x-user-id', 'dev-1') + .set('x-request-id', 'req-tenant-update') + .send({ name: 'GrantFox Stadium Ops', plan: 'enterprise' }); + + expect(res.status).toBe(200); + expect(res.body.data).toMatchObject({ + id: 'tenant_123', + name: 'GrantFox Stadium Ops', + plan: 'enterprise', + }); + expect(repository.update).toHaveBeenCalledWith( + 'tenant_123', + expect.objectContaining({ name: 'GrantFox Stadium Ops', plan: 'enterprise' }), + 'dev-1', + ); + }); + + it('returns structured 400 for invalid patch params and empty body', async () => { + const { app, repository } = buildApp(); + + const res = await request(app) + .patch('/api/tenants/no') + .set('x-user-id', 'dev-1') + .set('x-request-id', 'req-invalid-update') + .send({}); + + expect(res.status).toBe(400); + expect(res.body.requestId).toBe('req-invalid-update'); + expect(res.body.error.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: 'body' }), + expect.objectContaining({ field: 'params.tenantId' }), + ]), + ); + expect(repository.update).not.toHaveBeenCalled(); + }); + + it('uses the default repository to create and update tenants', async () => { + const app = buildDefaultRepositoryApp(); + + const created = await request(app) + .post('/api/tenants') + .set('x-user-id', 'dev-1') + .send({ name: 'AI' }); + + expect(created.status).toBe(201); + expect(created.body.data).toEqual( + expect.objectContaining({ + id: expect.stringMatching(/^ten_/), + name: 'AI', + slug: 'tenant-ai', + plan: 'starter', + createdBy: 'dev-1', + }), + ); + + const updated = await request(app) + .patch(`/api/tenants/${created.body.data.id}`) + .set('x-user-id', 'dev-1') + .send({ contactEmail: 'ai-ops@grantfox.test' }); + + expect(updated.status).toBe(200); + expect(updated.body.data).toEqual( + expect.objectContaining({ + id: created.body.data.id, + name: 'AI', + slug: 'tenant-ai', + contactEmail: 'ai-ops@grantfox.test', + }), + ); + }); + + it('routes repository errors through the error handler', async () => { + const repository = new MockTenantRepository(); + repository.create.mockRejectedValueOnce(new Error('tenant store unavailable')); + const { app } = buildApp(repository); + + const res = await request(app) + .post('/api/tenants') + .set('x-user-id', 'dev-1') + .send({ name: 'GrantFox Ops' }); + + expect(res.status).toBe(500); + expect(res.body.error.code).toBe('INTERNAL_SERVER_ERROR'); + }); + + it('routes update repository errors through the error handler', async () => { + const repository = new MockTenantRepository(); + repository.update.mockRejectedValueOnce(new Error('tenant store unavailable')); + const { app } = buildApp(repository); + + const res = await request(app) + .patch('/api/tenants/tenant_123') + .set('x-user-id', 'dev-1') + .send({ name: 'GrantFox Ops' }); + + expect(res.status).toBe(500); + expect(res.body.error.code).toBe('INTERNAL_SERVER_ERROR'); + }); +}); diff --git a/src/routes/tenants.ts b/src/routes/tenants.ts new file mode 100644 index 00000000..fac3bf3d --- /dev/null +++ b/src/routes/tenants.ts @@ -0,0 +1,153 @@ +import { Router, type Request, type Response } from 'express'; +import { randomUUID } from 'crypto'; +import { requireAuth, type AuthenticatedLocals } from '../middleware/requireAuth.js'; +import { bodyValidator, validate } from '../middleware/validate.js'; +import { buildSuccessEnvelope } from '../middleware/envelope.js'; +import { logger } from '../logger.js'; +import { + createTenantSchema, + tenantParamsSchema, + updateTenantSchema, + type CreateTenantInput, + type UpdateTenantInput, +} from '../validators/tenants.js'; + +export interface TenantRecord { + id: string; + name: string; + slug: string; + contactEmail?: string; + plan: 'starter' | 'growth' | 'enterprise'; + metadata?: Record; + createdBy: string; + createdAt: string; + updatedAt: string; +} + +export interface TenantRepository { + create(input: CreateTenantInput, actorId: string): Promise; + update(tenantId: string, input: UpdateTenantInput, actorId: string): Promise; +} + +class InMemoryTenantRepository implements TenantRepository { + private tenants = new Map(); + + async create(input: CreateTenantInput, actorId: string): Promise { + const now = new Date().toISOString(); + const slug = input.slug ?? slugify(input.name); + const tenant: TenantRecord = { + id: `ten_${randomUUID()}`, + name: input.name, + slug, + contactEmail: input.contactEmail, + plan: input.plan, + metadata: input.metadata, + createdBy: actorId, + createdAt: now, + updatedAt: now, + }; + this.tenants.set(tenant.id, tenant); + return tenant; + } + + async update(tenantId: string, input: UpdateTenantInput, _actorId: string): Promise { + const existing = this.tenants.get(tenantId) ?? { + id: tenantId, + name: 'Existing tenant', + slug: slugify(tenantId), + plan: 'starter' as const, + createdBy: 'system', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const updated: TenantRecord = { + ...existing, + ...input, + updatedAt: new Date().toISOString(), + }; + this.tenants.set(tenantId, updated); + return updated; + } +} + +export interface TenantsRouterDeps { + tenantRepository?: TenantRepository; +} + +function slugify(value: string): string { + const slug = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 63); + + return slug.length >= 3 ? slug : `tenant-${slug || 'new'}`; +} + +function requestId(req: Request): string { + return req.id || 'unknown'; +} + +function correlationId(req: Request): string { + return req.header('x-correlation-id') || requestId(req); +} + +export function createTenantsRouter(deps: TenantsRouterDeps = {}): Router { + const router = Router(); + const tenantRepository = deps.tenantRepository ?? new InMemoryTenantRepository(); + + router.post( + '/', + requireAuth, + bodyValidator(createTenantSchema), + async (req: Request, res: Response, next) => { + try { + const actorId = res.locals.authenticatedUser!.id; + const body = createTenantSchema.parse(req.body); + const tenant = await tenantRepository.create(body, actorId); + + logger.info('[tenants] tenant created', { + requestId: requestId(req), + correlationId: correlationId(req), + tenantId: tenant.id, + actorId, + slug: tenant.slug, + }); + + res.status(201).json(buildSuccessEnvelope(tenant, requestId(req))); + } catch (error) { + next(error); + } + }, + ); + + router.patch( + '/:tenantId', + requireAuth, + validate({ params: tenantParamsSchema, body: updateTenantSchema }), + async (req: Request, res: Response, next) => { + try { + const actorId = res.locals.authenticatedUser!.id; + const { tenantId } = tenantParamsSchema.parse(req.params); + const body = updateTenantSchema.parse(req.body); + const tenant = await tenantRepository.update(tenantId, body, actorId); + + logger.info('[tenants] tenant updated', { + requestId: requestId(req), + correlationId: correlationId(req), + tenantId, + actorId, + }); + + res.json(buildSuccessEnvelope(tenant, requestId(req))); + } catch (error) { + next(error); + } + }, + ); + + return router; +} + +export default createTenantsRouter(); diff --git a/src/validators/tenants.test.ts b/src/validators/tenants.test.ts new file mode 100644 index 00000000..149e3d50 --- /dev/null +++ b/src/validators/tenants.test.ts @@ -0,0 +1,72 @@ +import { + createTenantSchema, + tenantParamsSchema, + updateTenantSchema, +} from './tenants.js'; + +describe('tenant validators', () => { + it('normalizes valid create tenant input and applies default plan', () => { + const parsed = createTenantSchema.parse({ + name: ' GrantFox Ops ', + slug: 'GrantFox-Ops', + contactEmail: 'ops@grantfox.test', + metadata: { campaign: 'fwc26', priority: 1, active: true }, + }); + + expect(parsed).toEqual({ + name: 'GrantFox Ops', + slug: 'grantfox-ops', + contactEmail: 'ops@grantfox.test', + plan: 'starter', + metadata: { campaign: 'fwc26', priority: 1, active: true }, + }); + }); + + it('rejects unknown create fields', () => { + const result = createTenantSchema.safeParse({ + name: 'GrantFox Ops', + unsafeRole: 'admin', + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.code).toBe('unrecognized_keys'); + } + }); + + it('rejects invalid contact email', () => { + const result = createTenantSchema.safeParse({ + name: 'GrantFox Ops', + contactEmail: 'not-email', + }); + + expect(result.success).toBe(false); + }); + + it('rejects metadata with too many keys', () => { + const metadata = Object.fromEntries( + Array.from({ length: 21 }, (_, index) => [`k${index}`, `v${index}`]), + ); + + const result = createTenantSchema.safeParse({ + name: 'GrantFox Ops', + metadata, + }); + + expect(result.success).toBe(false); + }); + + it('requires at least one update field', () => { + const result = updateTenantSchema.safeParse({}); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe('At least one tenant field must be provided'); + } + }); + + it('accepts bounded tenant route params', () => { + expect(tenantParamsSchema.parse({ tenantId: 'tenant_123' })).toEqual({ tenantId: 'tenant_123' }); + expect(tenantParamsSchema.safeParse({ tenantId: 'no' }).success).toBe(false); + }); +}); diff --git a/src/validators/tenants.ts b/src/validators/tenants.ts new file mode 100644 index 00000000..03bc0be5 --- /dev/null +++ b/src/validators/tenants.ts @@ -0,0 +1,72 @@ +import { z } from 'zod'; + +const slugPattern = /^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])?$/; +const tenantIdPattern = /^[A-Za-z0-9][A-Za-z0-9_-]{2,63}$/; + +const trimmedString = (fieldName: string, maxLength: number) => + z + .string({ + required_error: `${fieldName} is required`, + invalid_type_error: `${fieldName} must be a string`, + }) + .trim() + .min(1, `${fieldName} is required`) + .max(maxLength, `${fieldName} must be ${maxLength} characters or fewer`); + +export const tenantIdSchema = z + .string({ + required_error: 'tenantId is required', + invalid_type_error: 'tenantId must be a string', + }) + .trim() + .regex(tenantIdPattern, 'tenantId must be 3-64 characters using letters, numbers, underscores, or hyphens'); + +export const tenantSlugSchema = z + .string({ + invalid_type_error: 'slug must be a string', + }) + .trim() + .toLowerCase() + .regex(slugPattern, 'slug must be 3-63 lowercase letters, numbers, or hyphens without edge hyphens'); + +const tenantMetadataValueSchema = z.union([ + z.string().max(256, 'metadata values must be 256 characters or fewer'), + z.number().finite('metadata numbers must be finite'), + z.boolean(), +]); + +export const tenantMetadataSchema = z + .record(z.string().min(1).max(64), tenantMetadataValueSchema) + .refine((metadata) => Object.keys(metadata).length <= 20, { + message: 'metadata can contain at most 20 keys', + }); + +export const createTenantSchema = z + .object({ + name: trimmedString('name', 120), + slug: tenantSlugSchema.optional(), + contactEmail: z.string().trim().email('contactEmail must be a valid email address').max(254).optional(), + plan: z.enum(['starter', 'growth', 'enterprise']).default('starter'), + metadata: tenantMetadataSchema.optional(), + }) + .strict(); + +export const updateTenantSchema = z + .object({ + name: trimmedString('name', 120).optional(), + contactEmail: z.string().trim().email('contactEmail must be a valid email address').max(254).optional(), + plan: z.enum(['starter', 'growth', 'enterprise']).optional(), + metadata: tenantMetadataSchema.optional(), + }) + .strict() + .refine((body) => Object.keys(body).length > 0, { + message: 'At least one tenant field must be provided', + }); + +export const tenantParamsSchema = z.object({ + tenantId: tenantIdSchema, +}); + +export type CreateTenantInput = z.infer; +export type UpdateTenantInput = z.infer; +export type TenantParamsInput = z.infer;