- Datenmodell: Testphasen-Lebenszyklus am Mandanten (plan, trialEndsAt, readOnlySince, deletionDueAt, Versandmarker), TrialSignup (Plattform, Hashes statt Klartext), TenantExport (RLS), Onboarding-Status - /testen: 5-Schritte-Wizard (Betrieb, Admin-Konto, Enddatum, Einrichtung, Zusammenfassung), Bestätigung per POST, direkte Anmeldung über login-ticket; Rate-Limit je IP/E-Mail, Honeypot, Enumeration-Schutz, Slug-Kollisionen - Plattform: Wizard „Testmandant anlegen“ mit Einladung, Badges/Filter, Enddatum ändern, umwandeln, beenden, Löschung vormerken/abbrechen (Bestätigung + Audit) - Schreibsperre nach Ablauf zentral in moduleGuard und requireApiContext (non-GET über withApi), Upload-Routen, Einstellungen/Nutzerverwaltung, Worker-Jobs; Banner Backoffice + mobil - Datenexport (ZIP mit CSV/JSON + Dateien) als Worker-Job, auch im Nur-Lesen-Zustand - Täglicher Job trial-lifecycle: Erinnerungen 7/3/1, Ablauf, Löschhinweis, Löschung über das Offboarding - Erste-Schritte-Checkliste im Dashboard, Mail-Vorlagen de/en, Tests + Smoke, Betriebsdoku Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
110 lines
5.2 KiB
TypeScript
110 lines
5.2 KiB
TypeScript
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/<id>).
|
|
*
|
|
* 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<ServiceCtx> {
|
|
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<void> {
|
|
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<ServiceCtx> {
|
|
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 ?? []),
|
|
};
|
|
}
|