diff --git a/src/app/api/quota/pools/[id]/route.ts b/src/app/api/quota/pools/[id]/route.ts new file mode 100644 index 0000000000..239f1835f3 --- /dev/null +++ b/src/app/api/quota/pools/[id]/route.ts @@ -0,0 +1,99 @@ +/** + * GET /api/quota/pools/[id] — get a single quota pool + * PATCH /api/quota/pools/[id] — update pool name/allocations + * DELETE /api/quota/pools/[id] — delete pool + * + * Auth: requireManagementAuth + * Zod: PoolUpdateSchema from @/shared/schemas/quota (PATCH only) + * Audit: quota.pool.updated / quota.pool.deleted (B26) + * Sanitization: all error responses via buildErrorBody (Hard Rule #12, B25) + * + * Part of: Group B — REST routes for Quota Sharing (plan 22, frente F8). + */ + +import { NextResponse } from "next/server"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { PoolUpdateSchema } from "@/shared/schemas/quota"; +import { getPool, updatePool, deletePool } from "@/lib/localDb"; +import { logAuditEvent, getAuditRequestContext } from "@/lib/compliance/index"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export async function GET(request: Request, { params }: RouteParams): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { id } = await params; + const pool = getPool(id); + if (!pool) { + return NextResponse.json(buildErrorBody(404, "Pool not found"), { status: 404 }); + } + return NextResponse.json({ pool }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to get pool"; + return NextResponse.json(buildErrorBody(500, message), { status: 500 }); + } +} + +export async function PATCH(request: Request, { params }: RouteParams): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { id } = await params; + const body = await request.json().catch(() => null); + const parsed = PoolUpdateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json(buildErrorBody(400, parsed.error.message), { status: 400 }); + } + + const pool = updatePool(id, parsed.data); + if (!pool) { + return NextResponse.json(buildErrorBody(404, "Pool not found"), { status: 404 }); + } + + const ctx = getAuditRequestContext(request); + logAuditEvent({ + action: "quota.pool.updated", + target: id, + metadata: parsed.data, + ipAddress: ctx.ipAddress ?? undefined, + requestId: ctx.requestId, + }); + + return NextResponse.json({ pool }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to update pool"; + return NextResponse.json(buildErrorBody(500, message), { status: 500 }); + } +} + +export async function DELETE(request: Request, { params }: RouteParams): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { id } = await params; + const existed = deletePool(id); + if (!existed) { + return NextResponse.json(buildErrorBody(404, "Pool not found"), { status: 404 }); + } + + const ctx = getAuditRequestContext(request); + logAuditEvent({ + action: "quota.pool.deleted", + target: id, + ipAddress: ctx.ipAddress ?? undefined, + requestId: ctx.requestId, + }); + + return new Response(null, { status: 204 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to delete pool"; + return NextResponse.json(buildErrorBody(500, message), { status: 500 }); + } +} diff --git a/src/app/api/quota/pools/[id]/usage/route.ts b/src/app/api/quota/pools/[id]/usage/route.ts new file mode 100644 index 0000000000..727746f48a --- /dev/null +++ b/src/app/api/quota/pools/[id]/usage/route.ts @@ -0,0 +1,80 @@ +/** + * GET /api/quota/pools/[id]/usage — pool consumption snapshot with dimensions + * + * Resolves the pool's provider plan to get dimensions, then calls + * poolUsageWithDimensions on the concrete store implementation. + * + * Note on poolUsageWithDimensions availability: + * This method is defined on SqliteQuotaStore (and RedisQuotaStore) but is NOT + * part of the QuotaStore interface (keeping the interface minimal). F8 accesses + * it via dynamic type-narrowing: + * + * const storeExt = store as { poolUsageWithDimensions?: (...) => Promise<...> }; + * if (typeof storeExt.poolUsageWithDimensions === "function") { ... } + * else { fallback to store.poolUsage(id) } + * + * This avoids modifying the QuotaStore interface (F6 responsibility) while + * still using the richer method when available. + * + * Auth: requireManagementAuth + * Sanitization: all error responses via buildErrorBody (Hard Rule #12, B25) + * + * Part of: Group B — REST routes for Quota Sharing (plan 22, frente F8). + */ + +import { NextResponse } from "next/server"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { getPool } from "@/lib/localDb"; +import { getQuotaStore } from "@/lib/quota/QuotaStore"; +import { resolvePlan } from "@/lib/quota/planResolver"; +import type { PoolUsageSnapshot } from "@/lib/quota/types"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export async function GET(request: Request, { params }: RouteParams): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { id } = await params; + + // 1. Get pool — 404 if not found + const pool = getPool(id); + if (!pool) { + return NextResponse.json(buildErrorBody(404, "Pool not found"), { status: 404 }); + } + + // 2. Resolve the provider plan for this pool's connection + // Provider name is not stored on pool — use empty string to trigger catalog/empty fallback + const plan = resolvePlan(pool.connectionId, ""); + + // 3. Get the quota store and call poolUsageWithDimensions when available + const store = await getQuotaStore(); + + let snapshot: PoolUsageSnapshot; + const storeExt = store as unknown as { + poolUsageWithDimensions?: ( + poolId: string, + dimensions: Array<{ unit: string; window: string; limit: number }> + ) => Promise; + }; + + if ( + typeof storeExt.poolUsageWithDimensions === "function" && + plan.dimensions.length > 0 + ) { + snapshot = await storeExt.poolUsageWithDimensions(id, plan.dimensions); + } else { + // Fallback: use the interface-standard poolUsage (dimensions come from stored data only) + snapshot = await store.poolUsage(id); + } + + return NextResponse.json({ usage: snapshot }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to get pool usage"; + return NextResponse.json(buildErrorBody(500, message), { status: 500 }); + } +} diff --git a/src/app/api/quota/pools/route.ts b/src/app/api/quota/pools/route.ts new file mode 100644 index 0000000000..9a329679fe --- /dev/null +++ b/src/app/api/quota/pools/route.ts @@ -0,0 +1,63 @@ +/** + * GET /api/quota/pools — list all quota pools with allocations + * POST /api/quota/pools — create a new quota pool + * + * Auth: requireManagementAuth (same pattern as /api/compliance/audit-log) + * Zod: PoolCreateSchema from @/shared/schemas/quota + * Audit: quota.pool.created logged on POST (B26) + * Sanitization: all error responses via buildErrorBody (Hard Rule #12, B25) + * + * NOT LOCAL_ONLY — does not spawn processes (B18, Hard Rules #15/#17 do not apply). + * + * Part of: Group B — REST routes for Quota Sharing (plan 22, frente F8). + */ + +import { NextResponse } from "next/server"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { PoolCreateSchema } from "@/shared/schemas/quota"; +import { listPools, createPool } from "@/lib/localDb"; +import { logAuditEvent, getAuditRequestContext } from "@/lib/compliance/index"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const pools = listPools(); + return NextResponse.json({ pools }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to list pools"; + return NextResponse.json(buildErrorBody(500, message), { status: 500 }); + } +} + +export async function POST(request: Request): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const body = await request.json().catch(() => null); + const parsed = PoolCreateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json(buildErrorBody(400, parsed.error.message), { status: 400 }); + } + + const pool = createPool(parsed.data); + const ctx = getAuditRequestContext(request); + logAuditEvent({ + action: "quota.pool.created", + target: pool.id, + metadata: { connectionId: pool.connectionId, name: pool.name }, + ipAddress: ctx.ipAddress ?? undefined, + requestId: ctx.requestId, + }); + + return NextResponse.json({ pool }, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create pool"; + return NextResponse.json(buildErrorBody(500, message), { status: 500 }); + } +}