import { requireSession } from "@/server/auth"; import { dbForTenant, prisma } from "@/server/db"; import { writeAuditLog } from "@/server/audit"; import { isTokenStillValid } from "@/server/sessions"; import { assertModuleEnabled, requireModule } from "@/server/modules"; import type { Permission } from "@/server/rbac"; import type { ModuleKey } from "@/lib/modules"; import type { ServiceCtx } from "@/server/services/context"; import { ApiError, isMutatingApiRequest } from "@/server/api/respond"; import { assertTenantWritable } from "@/server/services/trial/state"; import { consumeRateLimit } from "@/server/rate-limit"; // assertSameOrigin lives in respond.ts (withApi applies it to every mutation); re-exported for // route handlers that do not use withApi (e.g. /documents/upload with HTML redirects). export { assertSameOrigin } from "@/server/api/respond"; /** * Service context for /api/v1 route handlers and other route handlers (e.g. /files/). * * Same authority model as `moduleGuard` (src/server/action-guard.ts, F-06): session cookie * (Auth.js), then membership status, identity status, session kill switch, forced password * change and the EFFECTIVE permissions are read from the database — never from the JWT. * Differences: failures are thrown as `ApiError` (401/403) so handlers can answer with JSON, * and `moduleKey` may be `null` for cross-module endpoints (document downloads are needed * by field, reports and documents alike). */ export async function requireApiContext(moduleKey: ModuleKey | null, ...permissions: Permission[]): Promise { let session; try { session = await requireSession(); } catch { throw new ApiError("unauthorized", "authentication required"); } const tenantId = session.user.tenantId; const db = dbForTenant(tenantId); const account = await db.user.findFirst({ where: { id: session.user.id, status: "ACTIVE" }, select: { userRoles: { select: { role: { select: { rolePermissions: { select: { permission: { select: { key: true } } } } } } } }, }, }); const identity = session.user.identityId ? await prisma.identity.findUnique({ where: { id: session.user.identityId }, select: { status: true, mustChangePassword: true, sessionsValidAfter: true }, }) : null; if (!account || !identity || identity.status !== "ACTIVE") { await writeAuditLog({ tenantId, actorId: session.user.id, action: "denied", entity: "account_inactive", entityId: session.user.id }); throw new ApiError("unauthorized", "account inactive"); } if (!isTokenStillValid(session.user.tokenIssuedAt, identity.sessionsValidAfter)) { throw new ApiError("unauthorized", "session invalidated"); } if (identity.mustChangePassword) throw new ApiError("forbidden", "password change required"); const effective = new Set(account.userRoles.flatMap((ur) => ur.role.rolePermissions.map((rp) => rp.permission.key))); for (const p of permissions) { if (!effective.has(p)) { await writeAuditLog({ tenantId, actorId: session.user.id, action: "denied", entity: "api", entityId: p }); throw new ApiError("forbidden", "forbidden"); } } if (moduleKey) await assertModuleEnabled(session, moduleKey); // throws ModuleDisabledError → 403 if (moduleKey) enforceApiRateLimit(session.user.id, moduleKey); await assertApiWriteAllowed(tenantId); return { db, tenantId, userId: session.user.id, permissions: effective }; } /** * L15 Testphase: non-GET /api/v1 requests (withApi) of an expired trial tenant → `blocked * trial_expired` (422). GET requests (reads, PDFs, downloads, export) are never blocked. Route * handlers outside `withApi` that write (backoffice upload) call `assertTenantWritable` themselves. */ export async function assertApiWriteAllowed(tenantId: string): Promise { if (isMutatingApiRequest()) await assertTenantWritable(tenantId); } /** * Per-user request budget for /api/v1 (in-memory, per app instance — see rate-limit.ts). * Field endpoints (sync outbox, uploads, offline pre-download, document cache) get the generous * `apiField` bucket, everything else `api`. `moduleKey = null` callers (/files downloads, the * EXEMPT lotse-settings action) are not /api/v1 endpoints and are not counted. * Exceeded → ApiError `rate_limited` (429 + Retry-After). */ export function enforceApiRateLimit(userId: string, moduleKey: ModuleKey): void { const scope = moduleKey === "field" ? "apiField" : "api"; const res = consumeRateLimit(scope, userId); if (!res.allowed) { throw new ApiError("rate_limited", "too many requests", { retryAfterSeconds: res.retryAfterSeconds }); } } /** * Read context for server components (pages). Uses the session's permission set (JWT), which * is the documented behaviour for read paths (AGENTS.md "Rollen"); mutations go through * moduleGuard / requireApiContext with DB-authoritative permissions. Also enforces the module gate. */ export async function requirePageContext(moduleKey: ModuleKey): Promise { const session = await requireModule(moduleKey); return { db: dbForTenant(session.user.tenantId), tenantId: session.user.tenantId, userId: session.user.id, permissions: new Set(session.user.permissions ?? []), }; }