154 lines
4.8 KiB
TypeScript
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;
|
|
}
|