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:
2026-03-27 14:16:39 +01:00
parent 70ae830623
commit 9d43e4b113
31 changed files with 1337 additions and 107 deletions
+25
View File
@@ -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);
+8
View File
@@ -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}`;
+1
View File
@@ -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";
+78
View File
@@ -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 };
}
+12 -4
View File
@@ -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;
}
+71
View File
@@ -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);
+13 -10
View File
@@ -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." };
+13 -9
View File
@@ -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",
+13 -10
View File
@@ -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({
+20 -8
View File
@@ -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",
+10 -7
View File
@@ -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 = "";
+145 -1
View File
@@ -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 };
}),
});
+11
View File
@@ -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,