feat: ACN Application Security Standard V7.30 compliance (19/23 items)
CRITICAL — Authentication & Access: - TOTP MFA: otpauth-based, QR setup UI, sign-in flow integration, admin disable override, /account/security self-service page - Session Timeouts: 8h absolute (maxAge), 30min idle (updateAge) - Failed Auth Logging: Pino warn for invalid password/user/totp, info for successful login, audit entries for all auth events - Concurrent Session Limit: ActiveSession model, oldest-kick strategy, max 3 per user (configurable in SystemSettings) CRITICAL — HTTP Security: - HSTS: max-age=31536000; includeSubDomains - CSP: script/style/img/font/connect-src with Gemini/OpenAI whitelist - X-XSS-Protection: 0 (CSP replaces legacy) - Auth page cache: no-store, no-cache, must-revalidate - Rate Limiting: 100/15min general API, 5/15min auth (Map-based) Data Protection: - XSS Sanitization: DOMPurify on comment bodies - autocomplete="new-password" on all password/secret fields - SameSite=Strict on all cookies (Credentials-only, no OAuth) - File Upload Magic Bytes validation (PNG/JPEG/WebP/GIF/BMP/TIFF) Logging & Monitoring: - Login/Logout audit entries (Auth entityType) - External API call logging with timing (OpenAI, Gemini) - Input validation failure logging at warn level - Concurrent session tracking in ActiveSession table Documentation: - docs/security-architecture.md (11 sections) - docs/sdlc.md (CI pipeline, security gates, incident response) - .gitea/PULL_REQUEST_TEMPLATE.md (security checklist) Schema: User.totpSecret/totpEnabled, SystemSettings.sessionMaxAge/ sessionIdleTimeout/maxConcurrentSessions, ActiveSession model Tests: 310 engine + 37 staffing pass. TypeScript clean. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -9,24 +9,26 @@
|
||||
"./trpc": "./src/trpc.ts",
|
||||
"./sse": "./src/sse/event-bus.ts",
|
||||
"./lib/reminder-scheduler": "./src/lib/reminder-scheduler.ts",
|
||||
"./lib/logger": "./src/lib/logger.ts"
|
||||
"./lib/logger": "./src/lib/logger.ts",
|
||||
"./middleware/rate-limit": "./src/middleware/rate-limit.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@capakraken/application": "workspace:*",
|
||||
"@capakraken/db": "workspace:*",
|
||||
"@capakraken/engine": "workspace:*",
|
||||
"@capakraken/shared": "workspace:*",
|
||||
"@capakraken/staffing": "workspace:*",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"ioredis": "^5.10.0",
|
||||
"nodemailer": "^8.0.1",
|
||||
"openai": "^6.27.0",
|
||||
"otpauth": "^9.5.0",
|
||||
"pino": "^10.3.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import OpenAI, { AzureOpenAI } from "openai";
|
||||
import { logger } from "./lib/logger.js";
|
||||
|
||||
type AiSettings = {
|
||||
aiProvider?: string | null;
|
||||
@@ -60,6 +61,30 @@ export function createDalleClient(settings: AiSettings): OpenAI {
|
||||
return new OpenAI({ apiKey: settings.azureOpenAiApiKey! });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an external AI API call with timing and structured logging.
|
||||
* Use this around any chat.completions.create / images.generate / responses.create call.
|
||||
*/
|
||||
export async function loggedAiCall<T>(
|
||||
provider: string,
|
||||
model: string,
|
||||
promptLength: number,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const result = await fn();
|
||||
const responseTimeMs = Math.round(performance.now() - start);
|
||||
logger.info({ provider, model, promptLength, responseTimeMs }, "External API call");
|
||||
return result;
|
||||
} catch (err) {
|
||||
const responseTimeMs = Math.round(performance.now() - start);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
logger.warn({ provider, model, promptLength, responseTimeMs, errorMessage }, "External API call failed");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Turns raw API errors into actionable human-readable messages. */
|
||||
export function parseAiError(err: unknown): string {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { logger } from "./lib/logger.js";
|
||||
|
||||
type GeminiSettings = {
|
||||
geminiApiKey?: string | null;
|
||||
geminiModel?: string | null;
|
||||
@@ -18,6 +20,7 @@ export async function generateGeminiImage(
|
||||
model = "gemini-2.5-flash-image",
|
||||
): Promise<string> {
|
||||
const fullPrompt = `Generate a professional, cinematic cover image for a 3D production project. ${prompt}`;
|
||||
const start = performance.now();
|
||||
|
||||
const response = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`,
|
||||
@@ -32,6 +35,7 @@ export async function generateGeminiImage(
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const responseTimeMs = Math.round(performance.now() - start);
|
||||
const body = await response.text();
|
||||
let msg = body;
|
||||
try {
|
||||
@@ -40,6 +44,7 @@ export async function generateGeminiImage(
|
||||
} catch {
|
||||
/* keep raw */
|
||||
}
|
||||
logger.warn({ provider: "gemini", model, promptLength: fullPrompt.length, responseTimeMs, status: response.status }, "External API call failed");
|
||||
throw new Error(`HTTP ${response.status}: ${msg}`);
|
||||
}
|
||||
|
||||
@@ -62,6 +67,9 @@ export async function generateGeminiImage(
|
||||
throw new Error("No image data returned from Gemini");
|
||||
}
|
||||
|
||||
const responseTimeMs = Math.round(performance.now() - start);
|
||||
logger.info({ provider: "gemini", model, promptLength: fullPrompt.length, responseTimeMs }, "External API call");
|
||||
|
||||
const base64 = imagePart.inlineData.data;
|
||||
const mimeType = imagePart.inlineData.mimeType ?? "image/png";
|
||||
return `data:${mimeType};base64,${base64}`;
|
||||
|
||||
@@ -11,3 +11,4 @@ export { checkVacationConflicts, checkBatchVacationConflicts } from "./lib/vacat
|
||||
export { lookupRate, type RateCardLookupParams, type RateCardLookupResult } from "./lib/rate-card-lookup.js";
|
||||
export { autoImportPublicHolidays, type AutoImportResult } from "./lib/holiday-auto-import.js";
|
||||
export { createAuditEntry, computeDiff, generateSummary } from "./lib/audit.js";
|
||||
export { loggedAiCall } from "./ai-client.js";
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Validates that the actual bytes of a base64-encoded image match its declared MIME type.
|
||||
* This prevents attackers from uploading malicious files with a spoofed extension/MIME.
|
||||
*/
|
||||
|
||||
interface MagicSignature {
|
||||
mimeType: string;
|
||||
bytes: number[];
|
||||
}
|
||||
|
||||
const SIGNATURES: MagicSignature[] = [
|
||||
{ mimeType: "image/png", bytes: [0x89, 0x50, 0x4e, 0x47] }, // .PNG
|
||||
{ mimeType: "image/jpeg", bytes: [0xff, 0xd8, 0xff] },
|
||||
{ mimeType: "image/webp", bytes: [0x52, 0x49, 0x46, 0x46] }, // RIFF (WebP starts with RIFF....WEBP)
|
||||
{ mimeType: "image/gif", bytes: [0x47, 0x49, 0x46, 0x38] }, // GIF8
|
||||
{ mimeType: "image/bmp", bytes: [0x42, 0x4d] }, // BM
|
||||
{ mimeType: "image/tiff", bytes: [0x49, 0x49, 0x2a, 0x00] }, // Little-endian TIFF
|
||||
{ mimeType: "image/tiff", bytes: [0x4d, 0x4d, 0x00, 0x2a] }, // Big-endian TIFF
|
||||
];
|
||||
|
||||
/**
|
||||
* Detects the actual MIME type of a binary buffer by checking magic bytes.
|
||||
* Returns null if no known image signature matches.
|
||||
*/
|
||||
export function detectImageMime(buffer: Uint8Array): string | null {
|
||||
for (const sig of SIGNATURES) {
|
||||
if (buffer.length >= sig.bytes.length && sig.bytes.every((b, i) => buffer[i] === b)) {
|
||||
// Extra check for WebP: bytes 8-11 must be "WEBP"
|
||||
if (sig.mimeType === "image/webp") {
|
||||
if (buffer.length < 12) continue;
|
||||
const webpTag = String.fromCharCode(buffer[8]!, buffer[9]!, buffer[10]!, buffer[11]!);
|
||||
if (webpTag !== "WEBP") continue;
|
||||
}
|
||||
return sig.mimeType;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a data URL by comparing its declared MIME type against the actual magic bytes.
|
||||
* Returns { valid: true } or { valid: false, reason: string }.
|
||||
*/
|
||||
export function validateImageDataUrl(dataUrl: string): { valid: true } | { valid: false; reason: string } {
|
||||
// Parse the data URL
|
||||
const match = dataUrl.match(/^data:(image\/[a-z+]+);base64,(.+)$/i);
|
||||
if (!match) {
|
||||
return { valid: false, reason: "Not a valid base64 image data URL." };
|
||||
}
|
||||
|
||||
const declaredMime = match[1]!.toLowerCase();
|
||||
const base64 = match[2]!;
|
||||
|
||||
// Decode at least the first 16 bytes for signature checking
|
||||
let buffer: Uint8Array;
|
||||
try {
|
||||
const chunk = base64.slice(0, 24); // 24 base64 chars = 18 bytes, more than enough
|
||||
buffer = Uint8Array.from(atob(chunk), (c) => c.charCodeAt(0));
|
||||
} catch {
|
||||
return { valid: false, reason: "Invalid base64 encoding." };
|
||||
}
|
||||
|
||||
const actualMime = detectImageMime(buffer);
|
||||
if (!actualMime) {
|
||||
return { valid: false, reason: "File content does not match any known image format." };
|
||||
}
|
||||
|
||||
// Allow JPEG variants (image/jpeg matches image/jpg header)
|
||||
const normalize = (m: string) => m.replace("image/jpg", "image/jpeg");
|
||||
if (normalize(declaredMime) !== normalize(actualMime)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `MIME type mismatch: declared "${declaredMime}" but actual content is "${actualMime}".`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
@@ -54,10 +54,18 @@ export async function loggingMiddleware(opts: {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
logger.error(
|
||||
{ ...logBase, durationMs, status: "error" as const, errorCode, errorMessage },
|
||||
"tRPC call failed",
|
||||
);
|
||||
// Log input validation failures at warn level (not error)
|
||||
if (errorCode === "BAD_REQUEST") {
|
||||
logger.warn(
|
||||
{ ...logBase, durationMs, status: "error" as const, errorCode, errorMessage },
|
||||
"Input validation failure",
|
||||
);
|
||||
} else {
|
||||
logger.error(
|
||||
{ ...logBase, durationMs, status: "error" as const, errorCode, errorMessage },
|
||||
"tRPC call failed",
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Simple in-memory rate limiter (Map-based).
|
||||
* Good enough for single-instance deployments.
|
||||
* For multi-instance, swap to Redis-backed implementation.
|
||||
*/
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
interface RateLimitResult {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
resetAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a sliding-window rate limiter.
|
||||
* @param windowMs - Time window in milliseconds
|
||||
* @param maxRequests - Maximum requests allowed within the window
|
||||
*/
|
||||
export function createRateLimiter(windowMs: number, maxRequests: number) {
|
||||
const store = new Map<string, RateLimitEntry>();
|
||||
|
||||
// Periodically clean up expired entries to prevent memory leaks
|
||||
const cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of store) {
|
||||
if (entry.resetAt <= now) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}, windowMs);
|
||||
|
||||
// Allow garbage collection if the process holds no other references
|
||||
if (cleanupInterval.unref) {
|
||||
cleanupInterval.unref();
|
||||
}
|
||||
|
||||
return function check(key: string): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const existing = store.get(key);
|
||||
|
||||
// Window expired or first request — start fresh
|
||||
if (!existing || existing.resetAt <= now) {
|
||||
const resetAt = now + windowMs;
|
||||
store.set(key, { count: 1, resetAt });
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: maxRequests - 1,
|
||||
resetAt: new Date(resetAt),
|
||||
};
|
||||
}
|
||||
|
||||
// Within the current window
|
||||
existing.count += 1;
|
||||
const allowed = existing.count <= maxRequests;
|
||||
return {
|
||||
allowed,
|
||||
remaining: Math.max(0, maxRequests - existing.count),
|
||||
resetAt: new Date(existing.resetAt),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** General API rate limiter: 100 requests per 15 minutes per key */
|
||||
export const apiRateLimiter = createRateLimiter(15 * 60 * 1000, 100);
|
||||
|
||||
/** Auth rate limiter: 5 attempts per 15 minutes per key */
|
||||
export const authRateLimiter = createRateLimiter(15 * 60 * 1000, 5);
|
||||
@@ -8,7 +8,7 @@ import { calculateAllocation, checkDuplicateAssignment, countWorkingDays } from
|
||||
import { computeBudgetStatus } from "@capakraken/engine";
|
||||
import type { PermissionKey } from "@capakraken/shared";
|
||||
import { parseTaskAction } from "@capakraken/shared";
|
||||
import { createAiClient, createDalleClient, isAiConfigured, isDalleConfigured, parseAiError } from "../ai-client.js";
|
||||
import { createAiClient, createDalleClient, isAiConfigured, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
|
||||
import { getTaskAction } from "../lib/task-actions.js";
|
||||
import { fmtEur } from "../lib/format-utils.js";
|
||||
import { resolveRecipients } from "../lib/notification-targeting.js";
|
||||
@@ -5327,15 +5327,18 @@ const executors = {
|
||||
const maxTokens = settings!.aiMaxCompletionTokens ?? 300;
|
||||
const temperature = settings!.aiTemperature ?? 1;
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
messages: [
|
||||
{ role: "system", content: "You are a project management analyst providing brief executive summaries. Be factual and action-oriented." },
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
max_completion_tokens: maxTokens,
|
||||
model,
|
||||
...(temperature !== 1 ? { temperature } : {}),
|
||||
});
|
||||
const provider = settings!.aiProvider ?? "openai";
|
||||
const completion = await loggedAiCall(provider, model, prompt.length, () =>
|
||||
client.chat.completions.create({
|
||||
messages: [
|
||||
{ role: "system", content: "You are a project management analyst providing brief executive summaries. Be factual and action-oriented." },
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
max_completion_tokens: maxTokens,
|
||||
model,
|
||||
...(temperature !== 1 ? { temperature } : {}),
|
||||
}),
|
||||
);
|
||||
const narrative = completion.choices[0]?.message?.content?.trim() ?? "";
|
||||
|
||||
if (!narrative) return { error: "AI returned an empty response." };
|
||||
|
||||
@@ -7,7 +7,7 @@ import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { resolvePermissions, type PermissionOverrides, type SystemRole } from "@capakraken/shared";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||
import { createAiClient, isAiConfigured, parseAiError } from "../ai-client.js";
|
||||
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
|
||||
import { TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
|
||||
|
||||
const MAX_TOOL_ITERATIONS = 8;
|
||||
@@ -167,15 +167,19 @@ export const assistantRouter = createTRPCRouter({
|
||||
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let response: any;
|
||||
const provider = settings!.aiProvider ?? "openai";
|
||||
const msgLen = openaiMessages.reduce((n, m) => n + (typeof m.content === "string" ? m.content.length : 0), 0);
|
||||
try {
|
||||
response = await client.chat.completions.create({
|
||||
model,
|
||||
messages: openaiMessages,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
tools: availableTools as any,
|
||||
max_completion_tokens: maxTokens,
|
||||
temperature,
|
||||
});
|
||||
response = await loggedAiCall(provider, model, msgLen, () =>
|
||||
client.chat.completions.create({
|
||||
model,
|
||||
messages: openaiMessages,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
tools: availableTools as any,
|
||||
max_completion_tokens: maxTokens,
|
||||
temperature,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createAiClient, isAiConfigured, parseAiError } from "../ai-client.js";
|
||||
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
|
||||
import { controllerProcedure, createTRPCRouter } from "../trpc.js";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
@@ -133,17 +133,20 @@ ${dataContext}`;
|
||||
const maxTokens = settings!.aiMaxCompletionTokens ?? 300;
|
||||
const temperature = settings!.aiTemperature ?? 1;
|
||||
|
||||
const provider = settings!.aiProvider ?? "openai";
|
||||
let narrative = "";
|
||||
try {
|
||||
const completion = await client.chat.completions.create({
|
||||
messages: [
|
||||
{ role: "system", content: "You are a project management analyst providing brief executive summaries. Be factual and action-oriented." },
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
max_completion_tokens: maxTokens,
|
||||
model,
|
||||
...(temperature !== 1 ? { temperature } : {}),
|
||||
});
|
||||
const completion = await loggedAiCall(provider, model, prompt.length, () =>
|
||||
client.chat.completions.create({
|
||||
messages: [
|
||||
{ role: "system", content: "You are a project management analyst providing brief executive summaries. Be factual and action-oriented." },
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
max_completion_tokens: maxTokens,
|
||||
model,
|
||||
...(temperature !== 1 ? { temperature } : {}),
|
||||
}),
|
||||
);
|
||||
narrative = completion.choices[0]?.message?.content?.trim() ?? "";
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -12,10 +12,11 @@ import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
||||
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
|
||||
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
|
||||
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||
import { createDalleClient, isDalleConfigured, parseAiError } from "../ai-client.js";
|
||||
import { createDalleClient, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
|
||||
import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../gemini-client.js";
|
||||
import { invalidateDashboardCache } from "../lib/cache.js";
|
||||
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
||||
import { validateImageDataUrl } from "../lib/image-validation.js";
|
||||
|
||||
const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload)
|
||||
|
||||
@@ -520,13 +521,15 @@ export const projectRouter = createTRPCRouter({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let response: any;
|
||||
try {
|
||||
response = await dalleClient.images.generate({
|
||||
model,
|
||||
prompt: finalPrompt,
|
||||
size: "1024x1024",
|
||||
n: 1,
|
||||
response_format: "b64_json",
|
||||
});
|
||||
response = await loggedAiCall("dalle", model, finalPrompt.length, () =>
|
||||
dalleClient.images.generate({
|
||||
model,
|
||||
prompt: finalPrompt,
|
||||
size: "1024x1024",
|
||||
n: 1,
|
||||
response_format: "b64_json",
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
@@ -568,6 +571,15 @@ export const projectRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Validate magic bytes match declared MIME type
|
||||
const magicCheck = validateImageDataUrl(input.imageDataUrl);
|
||||
if (!magicCheck.valid) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `File validation failed: ${magicCheck.reason}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.imageDataUrl.length > MAX_COVER_SIZE) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createAiClient, isAiConfigured } from "../ai-client.js";
|
||||
import { createAiClient, isAiConfigured, loggedAiCall } from "../ai-client.js";
|
||||
import {
|
||||
isChargeabilityActualBooking,
|
||||
isChargeabilityRelevantProject,
|
||||
@@ -795,13 +795,16 @@ export const resourceRouter = createTRPCRouter({
|
||||
const maxTokens = settings!.aiMaxCompletionTokens ?? 300;
|
||||
const temperature = settings!.aiTemperature ?? 1;
|
||||
|
||||
const provider = settings!.aiProvider ?? "openai";
|
||||
async function callChatCompletions(withTemperature: boolean) {
|
||||
return client.chat.completions.create({
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
max_completion_tokens: maxTokens,
|
||||
model,
|
||||
...(withTemperature && temperature !== 1 ? { temperature } : {}),
|
||||
});
|
||||
return loggedAiCall(provider, model, prompt.length, () =>
|
||||
client.chat.completions.create({
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
max_completion_tokens: maxTokens,
|
||||
model,
|
||||
...(withTemperature && temperature !== 1 ? { temperature } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let summary = "";
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Prisma } from "@capakraken/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import { adminProcedure, createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
|
||||
import { adminProcedure, createTRPCRouter, managerProcedure, protectedProcedure, publicProcedure } from "../trpc.js";
|
||||
import { createAuditEntry } from "../lib/audit.js";
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
@@ -39,6 +39,7 @@ export const userRouter = createTRPCRouter({
|
||||
lastLoginAt: true,
|
||||
lastActiveAt: true,
|
||||
permissionOverrides: true,
|
||||
totpEnabled: true,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
@@ -466,4 +467,147 @@ export const userRouter = createTRPCRouter({
|
||||
overrides: user.permissionOverrides as PermissionOverrides | null,
|
||||
};
|
||||
}),
|
||||
|
||||
// ─── TOTP / MFA ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Generate a new TOTP secret for the current user (not yet enabled). */
|
||||
generateTotpSecret: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
const { TOTP, Secret } = await import("otpauth");
|
||||
const secret = new Secret({ size: 20 });
|
||||
const totp = new TOTP({
|
||||
issuer: "CapaKraken",
|
||||
label: ctx.session.user?.email ?? ctx.dbUser!.id,
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret,
|
||||
});
|
||||
|
||||
// Store the secret (not yet enabled)
|
||||
await ctx.db.user.update({
|
||||
where: { id: ctx.dbUser!.id },
|
||||
data: { totpSecret: secret.base32 },
|
||||
});
|
||||
|
||||
const uri = totp.toString();
|
||||
return { secret: secret.base32, uri };
|
||||
}),
|
||||
|
||||
/** Verify a TOTP token and enable MFA for the current user. */
|
||||
verifyAndEnableTotp: protectedProcedure
|
||||
.input(z.object({ token: z.string().length(6) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await ctx.db.user.findUniqueOrThrow({
|
||||
where: { id: ctx.dbUser!.id },
|
||||
select: { id: true, name: true, email: true, totpSecret: true, totpEnabled: true },
|
||||
});
|
||||
|
||||
if (!user.totpSecret) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "No TOTP secret generated. Call generateTotpSecret first." });
|
||||
}
|
||||
if (user.totpEnabled) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP is already enabled." });
|
||||
}
|
||||
|
||||
const { TOTP, Secret } = await import("otpauth");
|
||||
const totp = new TOTP({
|
||||
issuer: "CapaKraken",
|
||||
label: user.email,
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: Secret.fromBase32(user.totpSecret),
|
||||
});
|
||||
|
||||
const delta = totp.validate({ token: input.token, window: 1 });
|
||||
if (delta === null) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid TOTP token." });
|
||||
}
|
||||
|
||||
await ctx.db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { totpEnabled: true },
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "User",
|
||||
entityId: user.id,
|
||||
entityName: `${user.name} (${user.email})`,
|
||||
action: "UPDATE",
|
||||
userId: user.id,
|
||||
source: "ui",
|
||||
summary: "Enabled TOTP MFA",
|
||||
});
|
||||
|
||||
return { enabled: true };
|
||||
}),
|
||||
|
||||
/** Admin override: disable TOTP for a specific user. */
|
||||
disableTotp: adminProcedure
|
||||
.input(z.object({ userId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await ctx.db.user.findUniqueOrThrow({
|
||||
where: { id: input.userId },
|
||||
select: { id: true, name: true, email: true, totpEnabled: true },
|
||||
});
|
||||
|
||||
await ctx.db.user.update({
|
||||
where: { id: input.userId },
|
||||
data: { totpEnabled: false, totpSecret: null },
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "User",
|
||||
entityId: user.id,
|
||||
entityName: `${user.name} (${user.email})`,
|
||||
action: "UPDATE",
|
||||
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
|
||||
source: "ui",
|
||||
summary: "Disabled TOTP MFA (admin override)",
|
||||
});
|
||||
|
||||
return { disabled: true };
|
||||
}),
|
||||
|
||||
/** Verify a TOTP token (used during the login flow — public procedure). */
|
||||
verifyTotp: publicProcedure
|
||||
.input(z.object({ userId: z.string(), token: z.string().length(6) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await ctx.db.user.findUniqueOrThrow({
|
||||
where: { id: input.userId },
|
||||
select: { id: true, totpSecret: true, totpEnabled: true },
|
||||
});
|
||||
|
||||
if (!user.totpEnabled || !user.totpSecret) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP is not enabled for this user." });
|
||||
}
|
||||
|
||||
const { TOTP, Secret } = await import("otpauth");
|
||||
const totp = new TOTP({
|
||||
issuer: "CapaKraken",
|
||||
label: user.id,
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: Secret.fromBase32(user.totpSecret),
|
||||
});
|
||||
|
||||
const delta = totp.validate({ token: input.token, window: 1 });
|
||||
if (delta === null) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." });
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}),
|
||||
|
||||
/** Get MFA status for the current user. */
|
||||
getMfaStatus: protectedProcedure.query(async ({ ctx }) => {
|
||||
const user = await ctx.db.user.findUniqueOrThrow({
|
||||
where: { id: ctx.dbUser!.id },
|
||||
select: { totpEnabled: true },
|
||||
});
|
||||
return { totpEnabled: user.totpEnabled };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { resolvePermissions, PermissionKey, SystemRole } from "@capakraken/share
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import { ZodError } from "zod";
|
||||
import { loggingMiddleware } from "./middleware/logging.js";
|
||||
import { apiRateLimiter } from "./middleware/rate-limit.js";
|
||||
|
||||
// Minimal Session type to avoid next-auth peer-dep in this package
|
||||
interface Session {
|
||||
@@ -100,6 +101,16 @@ export const protectedProcedure = t.procedure.use(withLogging).use(({ ctx, next
|
||||
if (!ctx.dbUser) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "User account not found" });
|
||||
}
|
||||
|
||||
// Rate limit by user ID
|
||||
const rateLimitResult = apiRateLimiter(ctx.dbUser.id);
|
||||
if (!rateLimitResult.allowed) {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: `Rate limit exceeded. Try again after ${rateLimitResult.resetAt.toISOString()}`,
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
|
||||
Reference in New Issue
Block a user