Files
craftvia/src/server/api/context.ts
T
msolarczekandClaude Opus 5 d9290a187c L15 Testphase & Onboarding: Selbstanmeldung mit Double-Opt-in, Plattform-Wizard, Nur-Lesen-Sperre, Export, Lebenszyklus-Job
- 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>
2026-09-15 19:01:47 +02:00

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 ?? []),
};
}