Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/chat/services/chatAuditSession.server.ts
T

154 lines
4.8 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { createCookie } from "react-router";
import { loadRequiredWebConfig, resolveHostOnlyCookiePolicy } from "@hivegrid/config";
export const CHAT_AUDIT_SESSION_TTL_MS = 15 * 60 * 1_000;
export const CHAT_AUDIT_REASON_MAX_LENGTH = 500;
const CLOCK_SKEW_MS = 30 * 1_000;
const SECURE_COOKIE_NAME = "__Host-hivegrid_chat_audit";
const LOCAL_COOKIE_NAME = "hivegrid_chat_audit";
const PAYLOAD_VERSION = 1;
export type ChatAuditSession = Readonly<{
id: string;
tenantId: string;
userId: string;
reason: string;
issuedAt: number;
expiresAt: number;
}>;
type StoredChatAuditSession = ChatAuditSession & { readonly version: typeof PAYLOAD_VERSION };
let cachedCookie: ReturnType<typeof createCookie> | null = null;
function getChatAuditCookie(): ReturnType<typeof createCookie> {
if (cachedCookie) return cachedCookie;
const config = loadRequiredWebConfig();
const policy = resolveHostOnlyCookiePolicy({
nodeEnv: config.nodeEnv,
routing: config.routing,
secureName: SECURE_COOKIE_NAME,
localName: LOCAL_COOKIE_NAME,
maxAge: Math.ceil(CHAT_AUDIT_SESSION_TTL_MS / 1_000),
});
cachedCookie = createCookie(policy.name, {
httpOnly: true,
sameSite: "strict",
path: policy.path,
secure: policy.secure,
maxAge: policy.maxAge,
secrets: config.sessionSecret ? [config.sessionSecret] : [],
});
return cachedCookie;
}
export async function beginChatAuditSession(input: {
readonly tenantId: string;
readonly userId: string;
readonly reason: string;
readonly now?: Date;
readonly sessionId?: string;
}): Promise<{ readonly session: ChatAuditSession; readonly cookie: string }> {
const now = input.now ?? new Date();
const reason = normalizeReason(input.reason);
const session: ChatAuditSession = {
id: normalizeIdentifier(input.sessionId ?? randomUUID(), "session id"),
tenantId: normalizeIdentifier(input.tenantId, "tenant id"),
userId: normalizeIdentifier(input.userId, "user id"),
reason,
issuedAt: now.getTime(),
expiresAt: now.getTime() + CHAT_AUDIT_SESSION_TTL_MS,
};
const stored: StoredChatAuditSession = { version: PAYLOAD_VERSION, ...session };
return { session, cookie: await getChatAuditCookie().serialize(stored) };
}
export async function readChatAuditSession(input: {
readonly request: Request;
readonly tenantId: string;
readonly userId: string;
readonly now?: Date;
}): Promise<ChatAuditSession | null> {
let parsed: unknown;
try {
parsed = await getChatAuditCookie().parse(input.request.headers.get("cookie"));
} catch {
return null;
}
const session = parseStoredSession(parsed);
if (!session) return null;
const now = (input.now ?? new Date()).getTime();
if (
session.tenantId !== input.tenantId ||
session.userId !== input.userId ||
now < session.issuedAt - CLOCK_SKEW_MS ||
now >= session.expiresAt ||
session.expiresAt - session.issuedAt !== CHAT_AUDIT_SESSION_TTL_MS
) {
return null;
}
return session;
}
export function clearChatAuditSession(): Promise<string> {
return getChatAuditCookie().serialize("", { maxAge: 0, expires: new Date(0) });
}
export function normalizeChatAuditReason(value: unknown): string | null {
if (typeof value !== "string") return null;
try {
return normalizeReason(value);
} catch {
return null;
}
}
export function __resetChatAuditSessionCookieForTests(): void {
cachedCookie = null;
}
function parseStoredSession(value: unknown): ChatAuditSession | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const row = value as Record<string, unknown>;
if (
row.version !== PAYLOAD_VERSION ||
typeof row.id !== "string" ||
typeof row.tenantId !== "string" ||
typeof row.userId !== "string" ||
typeof row.reason !== "string" ||
typeof row.issuedAt !== "number" ||
typeof row.expiresAt !== "number" ||
!Number.isFinite(row.issuedAt) ||
!Number.isFinite(row.expiresAt)
) {
return null;
}
try {
return {
id: normalizeIdentifier(row.id, "session id"),
tenantId: normalizeIdentifier(row.tenantId, "tenant id"),
userId: normalizeIdentifier(row.userId, "user id"),
reason: normalizeReason(row.reason),
issuedAt: row.issuedAt,
expiresAt: row.expiresAt,
};
} catch {
return null;
}
}
function normalizeReason(value: string): string {
const reason = value.trim().replace(/\s+/g, " ");
if (!reason || reason.length > CHAT_AUDIT_REASON_MAX_LENGTH || reason.includes("\0")) {
throw new Error("A chat audit reason is required");
}
return reason;
}
function normalizeIdentifier(value: string, label: string): string {
const normalized = value.trim();
if (!normalized || normalized.length > 500 || normalized.includes("\0")) throw new Error(`${label} is invalid`);
return normalized;
}