diff --git a/README.md b/README.md index 337ee4f..00385f0 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,11 @@ API key for the metering flow until API-key enforcement lands. Add your own `X-Request-Id` header when you want to correlate client logs with backend responses. The backend echoes the value on success and structured errors. +Write endpoints use shared request-body schemas before route handlers run. The +same schema registry backs the OpenAPI request-body components, rejects unknown +fields, and preserves the existing `400 invalid_request` response shape with a +client `message` and `requestId`. + Set a shell variable for the local base URL: ```bash diff --git a/src/middleware/validate.ts b/src/middleware/validate.ts new file mode 100644 index 0000000..825140d --- /dev/null +++ b/src/middleware/validate.ts @@ -0,0 +1,23 @@ +import type { NextFunction, Request, Response } from "express"; +import type { BodySchema } from "../schemas/requestBodies.js"; +import { getRequestId } from "../types.js"; + +/** + * Validates JSON request bodies before a route handler sees them. + */ +export function validateBody(schema: BodySchema) { + return (req: Request, res: Response, next: NextFunction): void => { + const parsed = schema.parse(req.body); + if (!parsed.ok) { + res.status(400).json({ + error: "invalid_request", + message: parsed.message, + requestId: getRequestId(req), + }); + return; + } + + req.body = parsed.value; + next(); + }; +} diff --git a/src/routes/apiKeys.ts b/src/routes/apiKeys.ts index ce8bf2a..08daff8 100644 --- a/src/routes/apiKeys.ts +++ b/src/routes/apiKeys.ts @@ -1,5 +1,7 @@ import { randomUUID } from "node:crypto"; import { Router, type Request, type Response } from "express"; +import { validateBody } from "../middleware/validate.js"; +import { requestBodySchemas } from "../schemas/requestBodies.js"; import { apiKeyStore } from "../store/state.js"; import { getRequestId } from "../types.js"; @@ -39,21 +41,16 @@ export function createApiKeysRouter(): Router { res.json({ items }); }); - router.post("/api/v1/api-keys", (req: Request, res: Response) => { - const { label } = req.body ?? {}; - const requestId = getRequestId(req); - if (typeof label !== "string" || label.length === 0 || label.length > 64) { - res.status(400).json({ - error: "invalid_request", - message: "label must be a non-empty string up to 64 chars", - requestId, - }); - return; + router.post( + "/api/v1/api-keys", + validateBody(requestBodySchemas.apiKeyCreate), + (req: Request, res: Response) => { + const { label } = req.body ?? {}; + const key = `apk_${randomUUID().replace(/-/g, "")}`; + apiKeyStore.set(key, { label, createdAt: Date.now() }); + res.status(201).json({ key, label }); } - const key = `apk_${randomUUID().replace(/-/g, "")}`; - apiKeyStore.set(key, { label, createdAt: Date.now() }); - res.status(201).json({ key, label }); - }); + ); return router; } diff --git a/src/routes/config.ts b/src/routes/config.ts index 83405ab..c901cad 100644 --- a/src/routes/config.ts +++ b/src/routes/config.ts @@ -1,6 +1,7 @@ import { Router, type Request, type Response } from "express"; +import { validateBody } from "../middleware/validate.js"; +import { requestBodySchemas } from "../schemas/requestBodies.js"; import { config } from "../store/state.js"; -import { getRequestId } from "../types.js"; const allowedConfigKeys = [ "rateLimitPerWindow", @@ -18,25 +19,20 @@ export function createConfigRouter(): Router { res.json({ config }); }); - router.patch("/api/v1/config", (req: Request, res: Response) => { - const requestId = getRequestId(req); - const updates = req.body ?? {}; - for (const k of allowedConfigKeys) { - if (k in updates) { - const v = updates[k]; - if (typeof v !== "number" || !Number.isInteger(v) || v <= 0) { - res.status(400).json({ - error: "invalid_request", - message: `${k} must be a positive integer`, - requestId, - }); - return; + router.patch( + "/api/v1/config", + validateBody(requestBodySchemas.configPatch), + (req: Request, res: Response) => { + const updates = req.body ?? {}; + for (const k of allowedConfigKeys) { + if (k in updates) { + const v = updates[k]; + config[k] = v; } - config[k] = v; } + res.json({ config }); } - res.json({ config }); - }); + ); return router; } diff --git a/src/routes/meta.ts b/src/routes/meta.ts index dc0af6a..2e7e743 100644 --- a/src/routes/meta.ts +++ b/src/routes/meta.ts @@ -1,4 +1,8 @@ import { Router, type Response } from "express"; +import { + jsonRequestBodyRef, + openApiRequestBodyComponents, +} from "../schemas/requestBodies.js"; import { pauseState } from "../store/state.js"; /** @@ -65,43 +69,101 @@ export function createMetaRouter(): Router { "/api/v1/events": { get: { summary: "Audit log (?since=&limit=)" } }, "/api/v1/config": { get: { summary: "Read runtime config" }, - patch: { summary: "Update runtime config" }, + patch: { + summary: "Update runtime config", + requestBody: jsonRequestBodyRef("configPatch"), + }, }, "/api/v1/services": { get: { summary: "List services" }, - post: { summary: "Register a service" }, + post: { + summary: "Register a service", + requestBody: jsonRequestBodyRef("serviceCreate"), + }, + }, + "/api/v1/services/bulk": { + post: { + summary: "Register services in bulk", + requestBody: jsonRequestBodyRef("bulkServices"), + }, }, "/api/v1/services/{serviceId}": { get: { summary: "Fetch one service" }, delete: { summary: "Unregister service" }, }, "/api/v1/services/{serviceId}/price": { - patch: { summary: "Update price only" }, + patch: { + summary: "Update price only", + requestBody: jsonRequestBodyRef("servicePricePatch"), + }, + }, + "/api/v1/services/{serviceId}/metadata": { + get: { summary: "Read service metadata" }, + put: { + summary: "Set service metadata", + requestBody: jsonRequestBodyRef("serviceMetadataPut"), + }, + }, + "/api/v1/services/{serviceId}/disabled": { + patch: { + summary: "Enable or disable a service", + requestBody: jsonRequestBodyRef("serviceDisabledPatch"), + }, }, "/api/v1/services/{serviceId}/agents": { get: { summary: "List agents on a service" }, }, "/api/v1/agents/{agent}/usage": { get: { summary: "Per-service usage" } }, "/api/v1/agents/{agent}/total": { get: { summary: "Lifetime total" } }, - "/api/v1/usage": { post: { summary: "Record usage" } }, - "/api/v1/usage/bulk": { post: { summary: "Batched record" } }, + "/api/v1/usage": { + post: { + summary: "Record usage", + requestBody: jsonRequestBodyRef("usageRecord"), + }, + }, + "/api/v1/usage/bulk": { + post: { + summary: "Batched record", + requestBody: jsonRequestBodyRef("bulkUsage"), + }, + }, "/api/v1/usage/{agent}/{serviceId}": { get: { summary: "Read accumulator" } }, "/api/v1/billing/{agent}/{serviceId}": { get: { summary: "Quote bill" } }, - "/api/v1/settle": { post: { summary: "Drain & quote bill" } }, + "/api/v1/settle": { + post: { + summary: "Drain & quote bill", + requestBody: jsonRequestBodyRef("settle"), + }, + }, "/api/v1/api-keys": { get: { summary: "List api keys" }, - post: { summary: "Create api key" }, + post: { + summary: "Create api key", + requestBody: jsonRequestBodyRef("apiKeyCreate"), + }, }, "/api/v1/api-keys/{prefix}": { delete: { summary: "Revoke by prefix" } }, "/api/v1/webhooks": { get: { summary: "List webhooks" }, - post: { summary: "Register webhook" }, + post: { + summary: "Register webhook", + requestBody: jsonRequestBodyRef("webhookCreate"), + }, + }, + "/api/v1/webhooks/{id}": { + delete: { summary: "Unregister webhook" }, + patch: { + summary: "Update webhook", + requestBody: jsonRequestBodyRef("webhookPatch"), + }, }, - "/api/v1/webhooks/{id}": { delete: { summary: "Unregister webhook" } }, "/api/v1/admin/pause": { post: { summary: "Pause writes" } }, "/api/v1/admin/unpause": { post: { summary: "Resume" } }, "/api/v1/admin/status": { get: { summary: "Read pause flag" } }, }, + components: { + schemas: openApiRequestBodyComponents, + }, }); }); diff --git a/src/routes/services.ts b/src/routes/services.ts index c606e5c..67ba913 100644 --- a/src/routes/services.ts +++ b/src/routes/services.ts @@ -1,5 +1,7 @@ import { createHash } from "node:crypto"; import { Router, type Request, type Response } from "express"; +import { validateBody } from "../middleware/validate.js"; +import { requestBodySchemas } from "../schemas/requestBodies.js"; import { servicesDisabled, servicesMetadata, @@ -16,6 +18,14 @@ type ServiceReadShape = { owner?: string; }; +type BulkServicesBody = { + items: { serviceId?: unknown; priceStroops?: unknown }[]; +}; +type ServiceCreateBody = { serviceId: string; priceStroops: number }; +type ServiceMetadataBody = { description: string; owner: string }; +type ServiceDisabledBody = { disabled: boolean }; +type ServicePriceBody = { priceStroops: number }; + /** * Builds the public read shape for service detail and list endpoints. */ @@ -39,75 +49,49 @@ export function createServicesRouter(): Router { const router = Router(); /** Registers up to 50 services while rejecting duplicate ids in the same batch. */ - router.post("/api/v1/services/bulk", (req: Request, res: Response) => { - const requestId = getRequestId(req); - const { items } = req.body ?? {}; - if (!Array.isArray(items) || items.length === 0 || items.length > 50) { - res.status(400).json({ - error: "invalid_request", - message: "items must be 1-50 entries", - requestId, - }); - return; - } - const serviceIdsAtBatchStart = new Set(servicesStore.keys()); - const seenServiceIds = new Set(); - const results = items.map( - (it: { serviceId?: unknown; priceStroops?: unknown }, i: number) => { - const { serviceId, priceStroops } = it ?? {}; - if ( - typeof serviceId !== "string" || - serviceId.length === 0 || - serviceId.length > 128 || - typeof priceStroops !== "number" || - !Number.isInteger(priceStroops) || - priceStroops < 0 - ) { - return { index: i, ok: false, error: "invalid_item" }; - } - if (seenServiceIds.has(serviceId)) { - return { index: i, ok: false, serviceId, error: "duplicate_in_batch" }; + router.post( + "/api/v1/services/bulk", + validateBody(requestBodySchemas.bulkServices), + (req: Request, res: Response) => { + const { items } = req.body as BulkServicesBody; + const serviceIdsAtBatchStart = new Set(servicesStore.keys()); + const seenServiceIds = new Set(); + const results = items.map( + (it: { serviceId?: unknown; priceStroops?: unknown }, i: number) => { + const { serviceId, priceStroops } = it ?? {}; + if ( + typeof serviceId !== "string" || + serviceId.length === 0 || + serviceId.length > 128 || + typeof priceStroops !== "number" || + !Number.isInteger(priceStroops) || + priceStroops < 0 + ) { + return { index: i, ok: false, error: "invalid_item" }; + } + if (seenServiceIds.has(serviceId)) { + return { index: i, ok: false, serviceId, error: "duplicate_in_batch" }; + } + seenServiceIds.add(serviceId); + const isNew = !serviceIdsAtBatchStart.has(serviceId); + servicesStore.set(serviceId, { priceStroops }); + return { index: i, ok: true, serviceId, priceStroops, created: isNew }; } - seenServiceIds.add(serviceId); - const isNew = !serviceIdsAtBatchStart.has(serviceId); - servicesStore.set(serviceId, { priceStroops }); - return { index: i, ok: true, serviceId, priceStroops, created: isNew }; - } - ); - res.status(201).json({ results }); - }); - - router.post("/api/v1/services", (req: Request, res: Response) => { - const { serviceId, priceStroops } = req.body ?? {}; - const requestId = getRequestId(req); - if ( - typeof serviceId !== "string" || - serviceId.length === 0 || - serviceId.length > 128 - ) { - res.status(400).json({ - error: "invalid_request", - message: "serviceId must be a non-empty string up to 128 chars", - requestId, - }); - return; + ); + res.status(201).json({ results }); } - if ( - typeof priceStroops !== "number" || - !Number.isInteger(priceStroops) || - priceStroops < 0 - ) { - res.status(400).json({ - error: "invalid_request", - message: "priceStroops must be a non-negative integer", - requestId, - }); - return; + ); + + router.post( + "/api/v1/services", + validateBody(requestBodySchemas.serviceCreate), + (req: Request, res: Response) => { + const { serviceId, priceStroops } = req.body as ServiceCreateBody; + const isNew = !servicesStore.has(serviceId); + servicesStore.set(serviceId, { priceStroops }); + res.status(isNew ? 201 : 200).json({ serviceId, priceStroops }); } - const isNew = !servicesStore.has(serviceId); - servicesStore.set(serviceId, { priceStroops }); - res.status(isNew ? 201 : 200).json({ serviceId, priceStroops }); - }); + ); router.get("/api/v1/services/:serviceId/usage", (req: Request, res: Response) => { const { serviceId } = req.params; @@ -170,37 +154,25 @@ export function createServicesRouter(): Router { res.json(serviceReadShape(serviceId, meta)); }); - router.put("/api/v1/services/:serviceId/metadata", (req: Request, res: Response) => { - const { serviceId } = req.params; - const requestId = getRequestId(req); - if (!servicesStore.has(serviceId)) { - res.status(404).json({ - error: "not_found", - message: `service ${serviceId} is not registered`, - requestId, - }); - return; - } - const { description, owner } = req.body ?? {}; - if (typeof description !== "string" || description.length > 256) { - res.status(400).json({ - error: "invalid_request", - message: "description must be a string up to 256 chars", - requestId, - }); - return; - } - if (typeof owner !== "string" || owner.length === 0 || owner.length > 256) { - res.status(400).json({ - error: "invalid_request", - message: "owner must be a non-empty string up to 256 chars", - requestId, - }); - return; + router.put( + "/api/v1/services/:serviceId/metadata", + validateBody(requestBodySchemas.serviceMetadataPut), + (req: Request, res: Response) => { + const { serviceId } = req.params; + const requestId = getRequestId(req); + if (!servicesStore.has(serviceId)) { + res.status(404).json({ + error: "not_found", + message: `service ${serviceId} is not registered`, + requestId, + }); + return; + } + const { description, owner } = req.body as ServiceMetadataBody; + servicesMetadata.set(serviceId, { description, owner }); + res.json({ serviceId, description, owner }); } - servicesMetadata.set(serviceId, { description, owner }); - res.json({ serviceId, description, owner }); - }); + ); router.get("/api/v1/services/:serviceId/metadata", (req: Request, res: Response) => { const { serviceId } = req.params; @@ -218,6 +190,7 @@ export function createServicesRouter(): Router { router.patch( "/api/v1/services/:serviceId/disabled", + validateBody(requestBodySchemas.serviceDisabledPatch), (req: Request, res: Response) => { const { serviceId } = req.params; const requestId = getRequestId(req); @@ -229,50 +202,34 @@ export function createServicesRouter(): Router { }); return; } - const { disabled } = req.body ?? {}; - if (typeof disabled !== "boolean") { - res.status(400).json({ - error: "invalid_request", - message: "disabled must be a boolean", - requestId, - }); - return; - } + const { disabled } = req.body as ServiceDisabledBody; if (disabled) servicesDisabled.add(serviceId); else servicesDisabled.delete(serviceId); res.json({ serviceId, disabled }); } ); - router.patch("/api/v1/services/:serviceId/price", (req: Request, res: Response) => { - const { serviceId } = req.params; - const requestId = getRequestId(req); - const meta = servicesStore.get(serviceId); - if (!meta) { - res.status(404).json({ - error: "not_found", - message: `service ${serviceId} is not registered`, - requestId, - }); - return; - } - const { priceStroops } = req.body ?? {}; - if ( - typeof priceStroops !== "number" || - !Number.isInteger(priceStroops) || - priceStroops < 0 - ) { - res.status(400).json({ - error: "invalid_request", - message: "priceStroops must be a non-negative integer", - requestId, - }); - return; + router.patch( + "/api/v1/services/:serviceId/price", + validateBody(requestBodySchemas.servicePricePatch), + (req: Request, res: Response) => { + const { serviceId } = req.params; + const requestId = getRequestId(req); + const meta = servicesStore.get(serviceId); + if (!meta) { + res.status(404).json({ + error: "not_found", + message: `service ${serviceId} is not registered`, + requestId, + }); + return; + } + const { priceStroops } = req.body as ServicePriceBody; + meta.priceStroops = priceStroops; + servicesStore.set(serviceId, meta); + res.json({ serviceId, ...meta }); } - meta.priceStroops = priceStroops; - servicesStore.set(serviceId, meta); - res.json({ serviceId, ...meta }); - }); + ); router.delete("/api/v1/services/:serviceId", (req: Request, res: Response) => { const { serviceId } = req.params; diff --git a/src/routes/usage.ts b/src/routes/usage.ts index cd28a50..790e679 100644 --- a/src/routes/usage.ts +++ b/src/routes/usage.ts @@ -1,5 +1,7 @@ import { Router, type Request, type Response } from "express"; import { recordEvent } from "../events.js"; +import { validateBody } from "../middleware/validate.js"; +import { requestBodySchemas } from "../schemas/requestBodies.js"; import { servicesDisabled, servicesStore, @@ -21,104 +23,82 @@ type BillingTotalBreakdown = { unpricedRequests: number; }; +type UsageRecordBody = { agent: string; serviceId: string; requests: number }; +type BulkUsageBody = { items: unknown[] }; +type SettleBody = { agent: string; serviceId: string }; + /** * Builds usage, billing, settlement, and agent rollup routes. */ export function createUsageRouter(): Router { const router = Router(); - router.post("/api/v1/usage", (req: Request, res: Response) => { - const { agent, serviceId, requests } = req.body ?? {}; - const requestId = getRequestId(req); - - if (typeof agent !== "string" || agent.length === 0 || agent.length > 256) { - res.status(400).json({ - error: "invalid_request", - message: "agent must be a non-empty string up to 256 chars", - requestId, - }); - return; - } - if ( - typeof serviceId !== "string" || - serviceId.length === 0 || - serviceId.length > 128 - ) { - res.status(400).json({ - error: "invalid_request", - message: "serviceId must be a non-empty string up to 128 chars", - requestId, - }); - return; - } - if (typeof requests !== "number" || !Number.isInteger(requests) || requests <= 0) { - res.status(400).json({ - error: "invalid_request", - message: "requests must be a positive integer", - requestId, - }); - return; - } + router.post( + "/api/v1/usage", + validateBody(requestBodySchemas.usageRecord), + (req: Request, res: Response) => { + const { agent, serviceId, requests } = req.body as UsageRecordBody; + const requestId = getRequestId(req); - if (servicesDisabled.has(serviceId)) { - res.status(409).json({ - error: "service_disabled", - message: `service ${serviceId} is currently disabled`, - requestId, - }); - return; - } - - const key = usageKey(agent, serviceId); - const prev = usageStore.get(key) ?? 0; - const total = Math.min(Number.MAX_SAFE_INTEGER, prev + requests); - usageStore.set(key, total); + if (servicesDisabled.has(serviceId)) { + res.status(409).json({ + error: "service_disabled", + message: `service ${serviceId} is currently disabled`, + requestId, + }); + return; + } - recordEvent("usage.recorded", { agent, serviceId, requests, total }); - res.status(201).json({ agent, serviceId, total }); - }); + const key = usageKey(agent, serviceId); + const prev = usageStore.get(key) ?? 0; + const total = Math.min(Number.MAX_SAFE_INTEGER, prev + requests); + usageStore.set(key, total); - router.post("/api/v1/usage/bulk", (req: Request, res: Response) => { - const requestId = getRequestId(req); - const { items } = req.body ?? {}; - if (!Array.isArray(items) || items.length === 0 || items.length > 100) { - res.status(400).json({ - error: "invalid_request", - message: "items must be a non-empty array of up to 100 entries", - requestId, - }); - return; + recordEvent("usage.recorded", { agent, serviceId, requests, total }); + res.status(201).json({ agent, serviceId, total }); } - const results: BulkUsageResult[] = []; - for (let i = 0; i < items.length; i++) { - const { agent, serviceId, requests } = items[i] ?? {}; - if ( - typeof agent !== "string" || - typeof serviceId !== "string" || - typeof requests !== "number" || - !Number.isInteger(requests) || - requests <= 0 - ) { - results.push({ index: i, ok: false, error: "invalid_item" }); - continue; + ); + + router.post( + "/api/v1/usage/bulk", + validateBody(requestBodySchemas.bulkUsage), + (req: Request, res: Response) => { + const { items } = req.body as BulkUsageBody; + const results: BulkUsageResult[] = []; + for (let i = 0; i < items.length; i++) { + const { agent, serviceId, requests } = (items[i] ?? {}) as { + agent?: unknown; + serviceId?: unknown; + requests?: unknown; + }; + if ( + typeof agent !== "string" || + typeof serviceId !== "string" || + typeof requests !== "number" || + !Number.isInteger(requests) || + requests <= 0 + ) { + results.push({ index: i, ok: false, error: "invalid_item" }); + continue; + } + const key = usageKey(agent, serviceId); + const total = Math.min( + Number.MAX_SAFE_INTEGER, + (usageStore.get(key) ?? 0) + requests + ); + usageStore.set(key, total); + recordEvent("usage.recorded", { + agent, + serviceId, + requests, + total, + bulk: true, + }); + results.push({ index: i, ok: true, total }); } - const key = usageKey(agent, serviceId); - const total = Math.min( - Number.MAX_SAFE_INTEGER, - (usageStore.get(key) ?? 0) + requests - ); - usageStore.set(key, total); - recordEvent("usage.recorded", { - agent, - serviceId, - requests, - total, - bulk: true, - }); - results.push({ index: i, ok: true, total }); + res.status(201).json({ results }); } - res.status(201).json({ results }); - }); + ); router.get("/api/v1/usage/:agent/:serviceId", (req: Request, res: Response) => { const { agent, serviceId } = req.params; @@ -191,25 +171,20 @@ export function createUsageRouter(): Router { }); }); - router.post("/api/v1/settle", (req: Request, res: Response) => { - const { agent, serviceId } = req.body ?? {}; - const requestId = getRequestId(req); - if (typeof agent !== "string" || typeof serviceId !== "string") { - res.status(400).json({ - error: "invalid_request", - message: "agent and serviceId are required strings", - requestId, - }); - return; + router.post( + "/api/v1/settle", + validateBody(requestBodySchemas.settle), + (req: Request, res: Response) => { + const { agent, serviceId } = req.body as SettleBody; + const key = usageKey(agent, serviceId); + const requests = usageStore.get(key) ?? 0; + const price = servicesStore.get(serviceId)?.priceStroops ?? 0; + const billedStroops = requests * price; + usageStore.set(key, 0); + recordEvent("usage.settled", { agent, serviceId, requests, billedStroops }); + res.json({ agent, serviceId, requests, priceStroops: price, billedStroops }); } - const key = usageKey(agent, serviceId); - const requests = usageStore.get(key) ?? 0; - const price = servicesStore.get(serviceId)?.priceStroops ?? 0; - const billedStroops = requests * price; - usageStore.set(key, 0); - recordEvent("usage.settled", { agent, serviceId, requests, billedStroops }); - res.json({ agent, serviceId, requests, priceStroops: price, billedStroops }); - }); + ); router.get("/api/v1/agents", (req: Request, res: Response) => { const limit = Math.min( diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index 3e362fb..98d1d03 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -1,6 +1,8 @@ import { randomUUID } from "node:crypto"; import { Router, type Request, type Response } from "express"; import { recordEvent } from "../events.js"; +import { validateBody } from "../middleware/validate.js"; +import { requestBodySchemas } from "../schemas/requestBodies.js"; import { webhookStore } from "../store/state.js"; import { getRequestId } from "../types.js"; @@ -48,76 +50,43 @@ export function createWebhooksRouter(): Router { res.json({ id, deliveredAt: Date.now(), simulated: true }); }); - router.patch("/api/v1/webhooks/:id", (req: Request, res: Response) => { - const { id } = req.params; - const requestId = getRequestId(req); - const existing = webhookStore.get(id); - if (!existing) { - res.status(404).json({ - error: "not_found", - message: `webhook ${id} not registered`, - requestId, - }); - return; - } - const { url, events } = req.body ?? {}; - if (url !== undefined) { - if (typeof url !== "string" || !/^https?:\/\//.test(url) || url.length > 2048) { - res.status(400).json({ - error: "invalid_request", - message: "url must be an http(s) URL up to 2048 chars", + router.patch( + "/api/v1/webhooks/:id", + validateBody(requestBodySchemas.webhookPatch), + (req: Request, res: Response) => { + const { id } = req.params; + const requestId = getRequestId(req); + const existing = webhookStore.get(id); + if (!existing) { + res.status(404).json({ + error: "not_found", + message: `webhook ${id} not registered`, requestId, }); return; } - existing.url = url; - } - if (events !== undefined) { - if ( - !Array.isArray(events) || - events.length === 0 || - events.some((e) => typeof e !== "string") - ) { - res.status(400).json({ - error: "invalid_request", - message: "events must be a non-empty array of strings", - requestId, - }); - return; + const { url, events } = req.body ?? {}; + if (url !== undefined) { + existing.url = url; + } + if (events !== undefined) { + existing.events = events; } - existing.events = events; + webhookStore.set(id, existing); + res.json({ id, ...existing }); } - webhookStore.set(id, existing); - res.json({ id, ...existing }); - }); + ); - router.post("/api/v1/webhooks", (req: Request, res: Response) => { - const { url, events } = req.body ?? {}; - const requestId = getRequestId(req); - if (typeof url !== "string" || !/^https?:\/\//.test(url) || url.length > 2048) { - res.status(400).json({ - error: "invalid_request", - message: "url must be an http(s) URL up to 2048 chars", - requestId, - }); - return; - } - if ( - !Array.isArray(events) || - events.length === 0 || - events.some((e) => typeof e !== "string") - ) { - res.status(400).json({ - error: "invalid_request", - message: "events must be a non-empty array of strings", - requestId, - }); - return; + router.post( + "/api/v1/webhooks", + validateBody(requestBodySchemas.webhookCreate), + (req: Request, res: Response) => { + const { url, events } = req.body ?? {}; + const id = `wh_${randomUUID().replace(/-/g, "").slice(0, 16)}`; + webhookStore.set(id, { url, events, createdAt: Date.now() }); + res.status(201).json({ id, url, events }); } - const id = `wh_${randomUUID().replace(/-/g, "").slice(0, 16)}`; - webhookStore.set(id, { url, events, createdAt: Date.now() }); - res.status(201).json({ id, url, events }); - }); + ); return router; } diff --git a/src/schema-validation.test.ts b/src/schema-validation.test.ts new file mode 100644 index 0000000..d96a085 --- /dev/null +++ b/src/schema-validation.test.ts @@ -0,0 +1,166 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import request from "supertest"; +import { createApp } from "./index.js"; +import { + openApiRequestBodyComponents, + requestBodySchemas, +} from "./schemas/requestBodies.js"; + +type SchemaName = keyof typeof requestBodySchemas; + +const expectedSchemaKeys = [ + "apiKeyCreate", + "bulkServices", + "bulkUsage", + "configPatch", + "serviceCreate", + "serviceDisabledPatch", + "serviceMetadataPut", + "servicePricePatch", + "settle", + "usageRecord", + "webhookCreate", + "webhookPatch", +].sort(); + +const schemaExamples: { + name: SchemaName; + valid: unknown; + invalid: unknown; + invalidMessage: RegExp; +}[] = [ + { + name: "apiKeyCreate", + valid: { label: "integration key" }, + invalid: { label: "" }, + invalidMessage: /label must be a non-empty string up to 64 chars/, + }, + { + name: "bulkServices", + valid: { items: [{ serviceId: "svc-a", priceStroops: 1 }] }, + invalid: { items: [] }, + invalidMessage: /items must be 1-50 entries/, + }, + { + name: "bulkUsage", + valid: { items: [{ agent: "agent-a", serviceId: "svc-a", requests: 1 }] }, + invalid: { items: Array.from({ length: 101 }, () => ({})) }, + invalidMessage: /items must be a non-empty array of up to 100 entries/, + }, + { + name: "configPatch", + valid: { rateLimitPerWindow: 10, rateLimitWindowMs: 1000, bulkMaxItems: 25 }, + invalid: { rateLimitPerWindow: 0 }, + invalidMessage: /rateLimitPerWindow must be a positive integer/, + }, + { + name: "serviceCreate", + valid: { serviceId: "svc-a", priceStroops: 0 }, + invalid: { serviceId: "", priceStroops: 0 }, + invalidMessage: /serviceId must be a non-empty string up to 128 chars/, + }, + { + name: "serviceDisabledPatch", + valid: { disabled: true }, + invalid: { disabled: "true" }, + invalidMessage: /disabled must be a boolean/, + }, + { + name: "serviceMetadataPut", + valid: { description: "Service description", owner: "owner-a" }, + invalid: { description: "Service description", owner: "" }, + invalidMessage: /owner must be a non-empty string up to 256 chars/, + }, + { + name: "servicePricePatch", + valid: { priceStroops: 3 }, + invalid: { priceStroops: -1 }, + invalidMessage: /priceStroops must be a non-negative integer/, + }, + { + name: "settle", + valid: { agent: "agent-a", serviceId: "svc-a" }, + invalid: { agent: "agent-a" }, + invalidMessage: /agent and serviceId are required strings/, + }, + { + name: "usageRecord", + valid: { agent: "agent-a", serviceId: "svc-a", requests: 1 }, + invalid: { agent: "agent-a", serviceId: "svc-a", requests: 1.5 }, + invalidMessage: /requests must be a positive integer/, + }, + { + name: "webhookCreate", + valid: { url: "https://example.com/hook", events: ["usage.recorded"] }, + invalid: { url: "ftp://example.com/hook", events: ["usage.recorded"] }, + invalidMessage: /url must be an http\(s\) URL up to 2048 chars/, + }, + { + name: "webhookPatch", + valid: { events: ["usage.recorded"] }, + invalid: { events: [] }, + invalidMessage: /events must be a non-empty array of strings/, + }, +]; + +void describe("schema-first request validation", () => { + void it("defines a request body schema for every body-bearing route", () => { + assert.deepStrictEqual(Object.keys(requestBodySchemas).sort(), expectedSchemaKeys); + assert.deepStrictEqual( + Object.keys(openApiRequestBodyComponents).sort(), + expectedSchemaKeys + ); + }); + + void it("publishes OpenAPI request body refs from the shared schema registry", async () => { + const res = await request(createApp()).get("/api/v1/openapi.json"); + assert.strictEqual(res.status, 200); + + const schemas = res.body.components?.schemas ?? {}; + for (const key of expectedSchemaKeys) { + assert.ok(schemas[key], `missing OpenAPI component for ${key}`); + } + + assert.strictEqual( + res.body.paths["/api/v1/services"].post.requestBody.content["application/json"] + .schema.$ref, + "#/components/schemas/serviceCreate" + ); + assert.strictEqual( + res.body.paths["/api/v1/config"].patch.requestBody.content["application/json"] + .schema.$ref, + "#/components/schemas/configPatch" + ); + }); + + void it("validates representative valid and invalid bodies for every schema", () => { + assert.strictEqual(schemaExamples.length, expectedSchemaKeys.length); + + for (const example of schemaExamples) { + const valid = requestBodySchemas[example.name].parse(example.valid); + assert.strictEqual(valid.ok, true, `${example.name} valid sample failed`); + + const invalid = requestBodySchemas[example.name].parse(example.invalid); + assert.strictEqual(invalid.ok, false, `${example.name} invalid sample passed`); + if (!invalid.ok) assert.match(invalid.message, example.invalidMessage); + } + }); + + void it("rejects extra request body fields with the standard error envelope", async () => { + const res = await request(createApp()) + .post("/api/v1/services") + .set("X-Request-Id", "schema-extra-field") + .send({ + serviceId: "svc-schema-extra", + priceStroops: 10, + unexpected: true, + }); + + assert.strictEqual(res.status, 400); + const body = res.body as { error: string; requestId: string; message: string }; + assert.strictEqual(body.error, "invalid_request"); + assert.strictEqual(body.requestId, "schema-extra-field"); + assert.match(body.message, /unexpected field: unexpected/); + }); +}); diff --git a/src/schemas/requestBodies.ts b/src/schemas/requestBodies.ts new file mode 100644 index 0000000..2972d6d --- /dev/null +++ b/src/schemas/requestBodies.ts @@ -0,0 +1,310 @@ +type JsonObject = Record; + +export type BodySchema = { + parse( + body: unknown + ): { ok: true; value: JsonObject } | { ok: false; message: string }; + openApi: JsonObject; +}; + +type FieldSchema = { + required: boolean; + validate(value: unknown): string | undefined; + openApi: JsonObject; +}; + +function isPlainObject(value: unknown): value is JsonObject { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function strictObjectSchema(fields: Record): BodySchema { + return { + parse(body: unknown) { + const value = body ?? {}; + if (!isPlainObject(value)) { + return { ok: false, message: "body must be a JSON object" }; + } + + const allowed = new Set(Object.keys(fields)); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) { + return { ok: false, message: `unexpected field: ${key}` }; + } + } + + for (const [key, field] of Object.entries(fields)) { + const fieldValue = value[key]; + if (fieldValue === undefined) { + if (field.required) { + return { + ok: false, + message: field.validate(fieldValue) ?? `${key} is required`, + }; + } + continue; + } + const message = field.validate(fieldValue); + if (message) return { ok: false, message }; + } + + return { ok: true, value }; + }, + openApi: { + type: "object", + additionalProperties: false, + required: Object.entries(fields) + .filter(([, field]) => field.required) + .map(([key]) => key), + properties: Object.fromEntries( + Object.entries(fields).map(([key, field]) => [key, field.openApi]) + ), + }, + }; +} + +function stringField( + message: string, + options: { + required?: boolean; + minLength?: number; + maxLength?: number; + pattern?: string; + } +): FieldSchema { + return { + required: options.required ?? true, + validate(value: unknown) { + if (typeof value !== "string") return message; + if (options.minLength !== undefined && value.length < options.minLength) { + return message; + } + if (options.maxLength !== undefined && value.length > options.maxLength) { + return message; + } + if (options.pattern !== undefined && !new RegExp(options.pattern).test(value)) { + return message; + } + return undefined; + }, + openApi: { + type: "string", + ...(options.minLength !== undefined ? { minLength: options.minLength } : {}), + ...(options.maxLength !== undefined ? { maxLength: options.maxLength } : {}), + ...(options.pattern !== undefined ? { pattern: options.pattern } : {}), + }, + }; +} + +function integerField( + message: string, + options: { required?: boolean; minimum?: number; exclusiveMinimum?: number } +): FieldSchema { + return { + required: options.required ?? true, + validate(value: unknown) { + if (typeof value !== "number" || !Number.isInteger(value)) return message; + if (options.minimum !== undefined && value < options.minimum) return message; + if (options.exclusiveMinimum !== undefined && value <= options.exclusiveMinimum) { + return message; + } + return undefined; + }, + openApi: { + type: "integer", + ...(options.minimum !== undefined ? { minimum: options.minimum } : {}), + ...(options.exclusiveMinimum !== undefined + ? { exclusiveMinimum: options.exclusiveMinimum } + : {}), + }, + }; +} + +function booleanField(message: string): FieldSchema { + return { + required: true, + validate(value: unknown) { + return typeof value === "boolean" ? undefined : message; + }, + openApi: { type: "boolean" }, + }; +} + +function stringArrayField( + message: string, + options: { required?: boolean; minItems?: number } +): FieldSchema { + return { + required: options.required ?? true, + validate(value: unknown) { + if (!Array.isArray(value)) return message; + if (options.minItems !== undefined && value.length < options.minItems) { + return message; + } + if (value.some((item) => typeof item !== "string")) return message; + return undefined; + }, + openApi: { + type: "array", + items: { type: "string" }, + ...(options.minItems !== undefined ? { minItems: options.minItems } : {}), + }, + }; +} + +function arrayField( + message: string, + options: { minItems: number; maxItems: number; itemSchema: JsonObject } +): FieldSchema { + return { + required: true, + validate(value: unknown) { + if (!Array.isArray(value)) return message; + if (value.length < options.minItems || value.length > options.maxItems) { + return message; + } + return undefined; + }, + openApi: { + type: "array", + minItems: options.minItems, + maxItems: options.maxItems, + items: options.itemSchema, + }, + }; +} + +const agentField = stringField("agent must be a non-empty string up to 256 chars", { + minLength: 1, + maxLength: 256, +}); + +const serviceIdField = stringField( + "serviceId must be a non-empty string up to 128 chars", + { minLength: 1, maxLength: 128 } +); + +const requestsField = integerField("requests must be a positive integer", { + exclusiveMinimum: 0, +}); + +const priceStroopsField = integerField("priceStroops must be a non-negative integer", { + minimum: 0, +}); + +const usageItemSchema = { + type: "object", + additionalProperties: false, + required: ["agent", "serviceId", "requests"], + properties: { + agent: agentField.openApi, + serviceId: serviceIdField.openApi, + requests: requestsField.openApi, + }, +}; + +const serviceItemSchema = { + type: "object", + additionalProperties: false, + required: ["serviceId", "priceStroops"], + properties: { + serviceId: serviceIdField.openApi, + priceStroops: priceStroopsField.openApi, + }, +}; + +const positiveRuntimeConfigField = (key: string) => + integerField(`${key} must be a positive integer`, { + required: false, + exclusiveMinimum: 0, + }); + +export const requestBodySchemas = { + apiKeyCreate: strictObjectSchema({ + label: stringField("label must be a non-empty string up to 64 chars", { + minLength: 1, + maxLength: 64, + }), + }), + bulkServices: strictObjectSchema({ + items: arrayField("items must be 1-50 entries", { + minItems: 1, + maxItems: 50, + itemSchema: serviceItemSchema, + }), + }), + bulkUsage: strictObjectSchema({ + items: arrayField("items must be a non-empty array of up to 100 entries", { + minItems: 1, + maxItems: 100, + itemSchema: usageItemSchema, + }), + }), + configPatch: strictObjectSchema({ + rateLimitPerWindow: positiveRuntimeConfigField("rateLimitPerWindow"), + rateLimitWindowMs: positiveRuntimeConfigField("rateLimitWindowMs"), + bulkMaxItems: positiveRuntimeConfigField("bulkMaxItems"), + }), + serviceCreate: strictObjectSchema({ + serviceId: serviceIdField, + priceStroops: priceStroopsField, + }), + serviceDisabledPatch: strictObjectSchema({ + disabled: booleanField("disabled must be a boolean"), + }), + serviceMetadataPut: strictObjectSchema({ + description: stringField("description must be a string up to 256 chars", { + maxLength: 256, + }), + owner: stringField("owner must be a non-empty string up to 256 chars", { + minLength: 1, + maxLength: 256, + }), + }), + servicePricePatch: strictObjectSchema({ + priceStroops: priceStroopsField, + }), + settle: strictObjectSchema({ + agent: stringField("agent and serviceId are required strings", {}), + serviceId: stringField("agent and serviceId are required strings", {}), + }), + usageRecord: strictObjectSchema({ + agent: agentField, + serviceId: serviceIdField, + requests: requestsField, + }), + webhookCreate: strictObjectSchema({ + url: stringField("url must be an http(s) URL up to 2048 chars", { + maxLength: 2048, + pattern: "^https?://", + }), + events: stringArrayField("events must be a non-empty array of strings", { + minItems: 1, + }), + }), + webhookPatch: strictObjectSchema({ + url: stringField("url must be an http(s) URL up to 2048 chars", { + required: false, + maxLength: 2048, + pattern: "^https?://", + }), + events: stringArrayField("events must be a non-empty array of strings", { + required: false, + minItems: 1, + }), + }), +} satisfies Record; + +export const openApiRequestBodyComponents = Object.fromEntries( + Object.entries(requestBodySchemas).map(([key, schema]) => [key, schema.openApi]) +); + +export function jsonRequestBodyRef(schemaName: keyof typeof requestBodySchemas) { + return { + required: true, + content: { + "application/json": { + schema: { $ref: `#/components/schemas/${schemaName}` }, + }, + }, + }; +}