diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..184de68
--- /dev/null
+++ b/.gitea/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,22 @@
+## Summary
+
+
+
+## Security Checklist
+
+- [ ] No secrets in code (API keys, passwords, tokens)
+- [ ] Input validation (Zod schema) on new endpoints
+- [ ] Audit logging for data mutations (`createAuditEntry`)
+- [ ] No SQL injection risk (Prisma ORM used, no raw queries)
+- [ ] XSS prevention (user-provided text properly escaped/sanitized)
+- [ ] RBAC permission check on new procedures (`requirePermission`)
+- [ ] No new dependencies with known vulnerabilities (`pnpm audit`)
+
+## Test Plan
+
+
+
+- [ ] Unit tests pass (`pnpm test:unit`)
+- [ ] TypeScript compiles (`tsc --noEmit`)
+- [ ] Linting passes (`pnpm lint`)
+- [ ] Manual testing performed
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index 55c04f1..f131a51 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -25,6 +25,16 @@ const nextConfig: NextConfig = {
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
+ { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
+ { key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://generativelanguage.googleapis.com https://*.openai.com https://*.azure.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" },
+ { key: "X-XSS-Protection", value: "0" },
+ ],
+ },
+ {
+ source: "/auth/:path*",
+ headers: [
+ { key: "Cache-Control", value: "no-store, no-cache, must-revalidate" },
+ { key: "Pragma", value: "no-cache" },
],
},
];
diff --git a/apps/web/package.json b/apps/web/package.json
index eb5b700..4f330d9 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -11,16 +11,16 @@
"test:e2e": "playwright test"
},
"dependencies": {
- "@dnd-kit/core": "^6.3.1",
- "@dnd-kit/sortable": "^10.0.0",
- "@dnd-kit/utilities": "^3.2.2",
- "@node-rs/argon2": "^2.0.2",
"@capakraken/api": "workspace:*",
"@capakraken/application": "workspace:*",
"@capakraken/db": "workspace:*",
"@capakraken/engine": "workspace:*",
"@capakraken/shared": "workspace:*",
"@capakraken/ui": "workspace:*",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ "@node-rs/argon2": "^2.0.2",
"@react-pdf/renderer": "^4.3.2",
"@sentry/nextjs": "^10.45.0",
"@tanstack/react-query": "^5.62.16",
@@ -29,9 +29,11 @@
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"clsx": "^2.1.1",
+ "dompurify": "^3.3.3",
"framer-motion": "^12.38.0",
"next": "^15.1.7",
"next-auth": "^5.0.0-beta.25",
+ "otpauth": "^9.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-force-graph-3d": "^1.29.1",
@@ -46,6 +48,7 @@
"devDependencies": {
"@capakraken/tsconfig": "workspace:*",
"@playwright/test": "^1.49.1",
+ "@types/dompurify": "^3.2.0",
"@types/node": "^22.10.2",
"@types/react": "^19.0.6",
"@types/react-dom": "^19.0.3",
diff --git a/apps/web/src/app/(app)/account/security/page.tsx b/apps/web/src/app/(app)/account/security/page.tsx
new file mode 100644
index 0000000..82fa8f0
--- /dev/null
+++ b/apps/web/src/app/(app)/account/security/page.tsx
@@ -0,0 +1,16 @@
+import { MfaSetup } from "~/components/security/MfaSetup.js";
+
+export default function SecurityPage() {
+ return (
+
(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -19,11 +22,35 @@ export default function SignInPage() {
const result = await signIn("credentials", {
email,
password,
+ ...(mfaRequired ? { totp } : {}),
redirect: false,
});
if (result?.error) {
- setError("Invalid email or password");
+ // Auth.js wraps authorize() errors in the error field
+ if (result.error.includes("MFA_REQUIRED")) {
+ setMfaRequired(true);
+ setLoading(false);
+ // Focus the TOTP input after render
+ setTimeout(() => totpInputRef.current?.focus(), 100);
+ return;
+ }
+ if (result.error.includes("INVALID_TOTP")) {
+ setError("Invalid verification code. Please try again.");
+ setTotp("");
+ setLoading(false);
+ return;
+ }
+ if (result.error.includes("Too many login attempts")) {
+ setError("Too many login attempts. Please try again later.");
+ } else {
+ setError("Invalid email or password");
+ }
+ // Reset MFA state on credential error
+ if (mfaRequired) {
+ setMfaRequired(false);
+ setTotp("");
+ }
} else {
router.push("/dashboard");
}
@@ -31,6 +58,12 @@ export default function SignInPage() {
setLoading(false);
}
+ function handleBackToLogin() {
+ setMfaRequired(false);
+ setTotp("");
+ setError("");
+ }
+
return (
@@ -66,8 +99,14 @@ export default function SignInPage() {
Welcome Back
-
Sign in to CapaKraken
-
Resource Planning, staffing, and forecasting.
+
+ {mfaRequired ? "Two-Factor Authentication" : "Sign in to CapaKraken"}
+
+
+ {mfaRequired
+ ? "Enter the 6-digit code from your authenticator app."
+ : "Resource Planning, staffing, and forecasting."}
+
)}
-
-
- Email
-
- setEmail(e.target.value)}
- className="app-input"
- placeholder="you@company.com"
- required
- />
-
+ {!mfaRequired && (
+ <>
+
+
+ Email
+
+ setEmail(e.target.value)}
+ className="app-input"
+ placeholder="you@company.com"
+ required
+ />
+
-
-
- Password
-
- setPassword(e.target.value)}
- className="app-input"
- placeholder="••••••••"
- required
- />
-
+
+
+ Password
+
+ setPassword(e.target.value)}
+ className="app-input"
+ placeholder="--------"
+ required
+ autoComplete="current-password"
+ />
+
+ >
+ )}
+
+ {mfaRequired && (
+
+
+ Verification Code
+
+
setTotp(e.target.value.replace(/\D/g, "").slice(0, 6))}
+ className="app-input text-center text-2xl font-mono tracking-[0.4em]"
+ placeholder="000000"
+ required
+ />
+
+ Open your authenticator app (e.g. Google Authenticator, Authy) and enter the current code.
+
+
+ )}
- {loading ? "Signing in..." : "Sign in"}
+ {loading ? "Signing in..." : mfaRequired ? "Verify" : "Sign in"}
+
+ {mfaRequired && (
+
+ Back to login
+
+ )}
diff --git a/apps/web/src/components/admin/SystemSettingsClient.tsx b/apps/web/src/components/admin/SystemSettingsClient.tsx
index 20e91cc..0b5774d 100644
--- a/apps/web/src/components/admin/SystemSettingsClient.tsx
+++ b/apps/web/src/components/admin/SystemSettingsClient.tsx
@@ -1142,6 +1142,7 @@ export function SystemSettingsClient() {
value={dalleApiKey}
onChange={(e) => setDalleApiKey(e.target.value)}
placeholder="Leave empty to use same API key as chat"
+ autoComplete="new-password"
/>
>
@@ -1170,6 +1171,7 @@ export function SystemSettingsClient() {
value={geminiApiKey}
onChange={(e) => setGeminiApiKey(e.target.value)}
placeholder={settings?.hasGeminiApiKey ? "•••••••• (key is stored)" : "Enter Gemini API key"}
+ autoComplete="new-password"
/>
{settings?.hasGeminiApiKey && !geminiApiKey && (
API key is stored.
diff --git a/apps/web/src/components/admin/UsersClient.tsx b/apps/web/src/components/admin/UsersClient.tsx
index 4e0aec9..042a19d 100644
--- a/apps/web/src/components/admin/UsersClient.tsx
+++ b/apps/web/src/components/admin/UsersClient.tsx
@@ -61,6 +61,7 @@ type UserRow = {
lastLoginAt: Date | null;
lastActiveAt: Date | null;
permissionOverrides: PermissionOverrides | null;
+ totpEnabled: boolean;
};
type EditState = {
@@ -196,6 +197,14 @@ export function UsersClient() {
onError: (err) => setActionError(err.message),
});
+ const disableTotpMutation = trpc.user.disableTotp.useMutation({
+ onSuccess: async () => {
+ await utils.user.list.invalidate();
+ setActionError(null);
+ },
+ onError: (err) => setActionError(err.message),
+ });
+
function openSetPassword(user: UserRow) {
setPasswordTarget({ userId: user.id, userName: user.name ?? user.email });
setNewPassword("");
@@ -519,17 +528,24 @@ export function UsersClient() {
- {isOnline(user) ? (
-
-
- Online
-
- ) : (
-
-
- Offline
-
- )}
+
+ {isOnline(user) ? (
+
+
+ Online
+
+ ) : (
+
+
+ Offline
+
+ )}
+ {user.totpEnabled && (
+
+ MFA
+
+ )}
+
{formatRelativeTime(user.lastLoginAt)}
@@ -550,6 +566,24 @@ export function UsersClient() {
Password
+ {user.totpEnabled && (
+ {
+ if (confirm(`Disable MFA for ${user.name ?? user.email}?`)) {
+ void disableTotpMutation.mutateAsync({ userId: user.id });
+ }
+ }}
+ disabled={disableTotpMutation.isPending}
+ className="inline-flex items-center gap-1 text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 font-medium"
+ title="Disable TOTP MFA for this user"
+ >
+
+
+
+ Disable MFA
+
+ )}
openEdit(user)}
@@ -700,6 +734,7 @@ export function UsersClient() {
onChange={(e) => setCreateState({ ...createState, password: e.target.value })}
placeholder="Min. 8 characters"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
+ autoComplete="new-password"
/>
diff --git a/apps/web/src/components/admin/WebhooksClient.tsx b/apps/web/src/components/admin/WebhooksClient.tsx
index 48c2f01..469ebdb 100644
--- a/apps/web/src/components/admin/WebhooksClient.tsx
+++ b/apps/web/src/components/admin/WebhooksClient.tsx
@@ -344,6 +344,7 @@ export function WebhooksClient() {
value={form.secret}
onChange={(e) => setForm((prev) => ({ ...prev, secret: e.target.value }))}
placeholder="HMAC signing secret"
+ autoComplete="new-password"
/>
If set, requests include an X-Webhook-Signature header (HMAC-SHA256).
diff --git a/apps/web/src/components/comments/CommentThread.tsx b/apps/web/src/components/comments/CommentThread.tsx
index 06ed9cc..f03bb0d 100644
--- a/apps/web/src/components/comments/CommentThread.tsx
+++ b/apps/web/src/components/comments/CommentThread.tsx
@@ -5,6 +5,7 @@ import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { CommentInput } from "./CommentInput.js";
+import { sanitizeHtml } from "~/lib/sanitize.js";
interface CommentAuthor {
id: string;
@@ -72,21 +73,22 @@ function AuthorAvatar({ author }: { author: CommentAuthor }) {
* Transforms @[Name](userId) into styled spans.
*/
function CommentBody({ body }: { body: string }) {
+ const cleanBody = sanitizeHtml(body);
const parts: Array<{ type: "text" | "mention"; value: string }> = [];
const regex = /@\[([^\]]+)\]\([^)]+\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
- while ((match = regex.exec(body)) !== null) {
+ while ((match = regex.exec(cleanBody)) !== null) {
if (match.index > lastIndex) {
- parts.push({ type: "text", value: body.slice(lastIndex, match.index) });
+ parts.push({ type: "text", value: cleanBody.slice(lastIndex, match.index) });
}
parts.push({ type: "mention", value: `@${match[1]}` });
lastIndex = match.index + match[0].length;
}
- if (lastIndex < body.length) {
- parts.push({ type: "text", value: body.slice(lastIndex) });
+ if (lastIndex < cleanBody.length) {
+ parts.push({ type: "text", value: cleanBody.slice(lastIndex) });
}
return (
diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx
index 9db18a3..b59989d 100644
--- a/apps/web/src/components/layout/AppShell.tsx
+++ b/apps/web/src/components/layout/AppShell.tsx
@@ -112,6 +112,9 @@ function UsersIcon() {
function SystemRolesIcon() {
return ;
}
+function SecurityIcon() {
+ return ;
+}
function SettingsIcon() {
return ;
}
@@ -197,6 +200,12 @@ const navSections: NavSection[] = [
{ href: "/vacations", label: "Vacation Mgmt", icon: , roles: ["ADMIN", "MANAGER"] },
],
},
+ {
+ label: "Account",
+ items: [
+ { href: "/account/security", label: "Security", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
+ ],
+ },
];
type AdminNavItem = { href: string; label: string; icon: ReactNode };
diff --git a/apps/web/src/components/security/MfaSetup.tsx b/apps/web/src/components/security/MfaSetup.tsx
new file mode 100644
index 0000000..9aca63d
--- /dev/null
+++ b/apps/web/src/components/security/MfaSetup.tsx
@@ -0,0 +1,193 @@
+"use client";
+
+import { useState } from "react";
+import { trpc } from "~/lib/trpc/client.js";
+
+type SetupStep = "idle" | "show-secret" | "verify" | "done";
+
+export function MfaSetup() {
+ const [step, setStep] = useState("idle");
+ const [secret, setSecret] = useState("");
+ const [uri, setUri] = useState("");
+ const [token, setToken] = useState("");
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+
+ const { data: mfaStatus, refetch } = trpc.user.getMfaStatus.useQuery();
+ const generateMutation = trpc.user.generateTotpSecret.useMutation();
+ const verifyMutation = trpc.user.verifyAndEnableTotp.useMutation();
+
+ async function handleGenerate() {
+ setError(null);
+ try {
+ const result = await generateMutation.mutateAsync();
+ setSecret(result.secret);
+ setUri(result.uri);
+ setStep("show-secret");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to generate TOTP secret");
+ }
+ }
+
+ async function handleVerify() {
+ setError(null);
+ try {
+ await verifyMutation.mutateAsync({ token });
+ setStep("done");
+ setSuccess("MFA has been enabled successfully.");
+ setSecret("");
+ setUri("");
+ setToken("");
+ await refetch();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Verification failed");
+ }
+ }
+
+ if (mfaStatus?.totpEnabled && step !== "done") {
+ return (
+
+
+
+
+
MFA Enabled
+
+ Two-factor authentication is active on your account.
+
+
+
+
+ );
+ }
+
+ return (
+
+ {error && (
+
+ {error}
+
+ )}
+ {success && (
+
+ {success}
+
+ )}
+
+ {step === "idle" && (
+
+
+
+
+
Two-Factor Authentication (TOTP)
+
+ Add an extra layer of security by requiring a code from your authenticator app when signing in.
+
+
+ {generateMutation.isPending ? "Generating..." : "Set up MFA"}
+
+
+
+
+ )}
+
+ {step === "show-secret" && (
+
+
Step 1: Scan the QR code
+
+ Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.).
+
+
+ {/* QR Code via public Google Charts API (otpauth URI) */}
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
+
+
+
+ Or enter this key manually:
+
+
+ {secret}
+
+
+
+
{ setStep("verify"); setError(null); }}
+ className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-brand-700 transition-colors"
+ >
+ Continue
+
+
+ )}
+
+ {step === "verify" && (
+
+
Step 2: Verify your code
+
+ Enter the 6-digit code from your authenticator app to confirm setup.
+
+
+
+
+ Verification Code
+
+ setToken(e.target.value.replace(/\D/g, "").slice(0, 6))}
+ className="w-48 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-4 py-2 text-center text-xl font-mono tracking-[0.3em] text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
+ placeholder="000000"
+ autoFocus
+ />
+
+
+
+
+ {verifyMutation.isPending ? "Verifying..." : "Enable MFA"}
+
+ { setStep("show-secret"); setToken(""); setError(null); }}
+ className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
+ >
+ Back
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/lib/sanitize.ts b/apps/web/src/lib/sanitize.ts
new file mode 100644
index 0000000..5433bf8
--- /dev/null
+++ b/apps/web/src/lib/sanitize.ts
@@ -0,0 +1,11 @@
+import DOMPurify from "dompurify";
+
+/**
+ * Strip all HTML tags and attributes from a string.
+ * Returns plain text only (no tags, no attributes).
+ * SSR-safe: returns the input unchanged on the server.
+ */
+export function sanitizeHtml(dirty: string): string {
+ if (typeof window === "undefined") return dirty;
+ return DOMPurify.sanitize(dirty, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] });
+}
diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts
index 6cc673d..e51a636 100644
--- a/apps/web/src/server/auth.ts
+++ b/apps/web/src/server/auth.ts
@@ -1,4 +1,7 @@
import { prisma } from "@capakraken/db";
+import { authRateLimiter } from "@capakraken/api/middleware/rate-limit";
+import { createAuditEntry } from "@capakraken/api";
+import { logger } from "@capakraken/api/lib/logger";
import NextAuth, { type NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { verify } from "@node-rs/argon2";
@@ -7,6 +10,7 @@ import { z } from "zod";
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
+ totp: z.string().optional(),
});
const authConfig = {
@@ -16,17 +20,96 @@ const authConfig = {
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
+ totp: { label: "TOTP", type: "text" },
},
async authorize(credentials) {
const parsed = LoginSchema.safeParse(credentials);
if (!parsed.success) return null;
- const { email, password } = parsed.data;
+ const { email, password, totp } = parsed.data;
+
+ // Rate limit: 5 login attempts per 15 minutes per email
+ const rateLimitResult = authRateLimiter(email.toLowerCase());
+ if (!rateLimitResult.allowed) {
+ // Audit failed login (rate limited)
+ void createAuditEntry({
+ db: prisma,
+ entityType: "Auth",
+ entityId: email.toLowerCase(),
+ entityName: email,
+ action: "CREATE",
+ summary: "Login blocked — rate limit exceeded",
+ source: "ui",
+ });
+ throw new Error("Too many login attempts. Please try again later.");
+ }
+
const user = await prisma.user.findUnique({ where: { email } });
- if (!user?.passwordHash) return null;
+ if (!user?.passwordHash) {
+ logger.warn({ email, reason: "user_not_found" }, "Failed login attempt");
+ // Audit failed login (unknown user)
+ void createAuditEntry({
+ db: prisma,
+ entityType: "Auth",
+ entityId: email.toLowerCase(),
+ entityName: email,
+ action: "CREATE",
+ summary: "Login failed — user not found",
+ source: "ui",
+ });
+ return null;
+ }
const isValid = await verify(user.passwordHash, password);
- if (!isValid) return null;
+ if (!isValid) {
+ logger.warn({ email, reason: "invalid_password" }, "Failed login attempt");
+ // Audit failed login (bad password)
+ void createAuditEntry({
+ db: prisma,
+ entityType: "Auth",
+ entityId: user.id,
+ entityName: user.email,
+ action: "CREATE",
+ userId: user.id,
+ summary: "Login failed — invalid password",
+ source: "ui",
+ });
+ return null;
+ }
+
+ // MFA check: if TOTP is enabled, require the token
+ if (user.totpEnabled && user.totpSecret) {
+ if (!totp) {
+ // Signal to the client that MFA is required (include userId for re-submission)
+ throw new Error("MFA_REQUIRED:" + user.id);
+ }
+
+ const { TOTP, Secret } = await import("otpauth");
+ const totpInstance = new TOTP({
+ issuer: "CapaKraken",
+ label: user.email,
+ algorithm: "SHA1",
+ digits: 6,
+ period: 30,
+ secret: Secret.fromBase32(user.totpSecret),
+ });
+
+ const delta = totpInstance.validate({ token: totp, window: 1 });
+ if (delta === null) {
+ logger.warn({ email, reason: "invalid_totp" }, "Failed MFA verification");
+ void createAuditEntry({
+ db: prisma,
+ entityType: "Auth",
+ entityId: user.id,
+ entityName: user.email,
+ action: "CREATE",
+ userId: user.id,
+ summary: "Login failed — invalid TOTP token",
+ source: "ui",
+ });
+ throw new Error("INVALID_TOTP");
+ }
+ }
// Track last login time
await prisma.user.update({
@@ -34,6 +117,19 @@ const authConfig = {
data: { lastLoginAt: new Date() },
});
+ logger.info({ email, userId: user.id }, "Successful login");
+ // Audit successful login
+ void createAuditEntry({
+ db: prisma,
+ entityType: "Auth",
+ entityId: user.id,
+ entityName: user.email,
+ action: "CREATE",
+ userId: user.id,
+ summary: "User logged in",
+ source: "ui",
+ });
+
return {
id: user.id,
email: user.email,
@@ -56,15 +152,107 @@ const authConfig = {
async jwt({ token, user }) {
if (user) {
token.role = (user as typeof user & { role: string }).role;
+
+ // Generate a unique JWT ID for session tracking
+ const jti = crypto.randomUUID();
+ token.jti = jti;
+
+ // Enforce concurrent session limit (kick-oldest strategy)
+ try {
+ const settings = await prisma.systemSettings.findUnique({
+ where: { id: "singleton" },
+ select: { maxConcurrentSessions: true },
+ });
+ const maxSessions = settings?.maxConcurrentSessions ?? 3;
+
+ // Register this new session
+ await prisma.activeSession.create({
+ data: { userId: user.id!, jti },
+ });
+
+ // Count active sessions and delete the oldest if over the limit
+ const activeSessions = await prisma.activeSession.findMany({
+ where: { userId: user.id! },
+ orderBy: { createdAt: "asc" },
+ select: { id: true },
+ });
+
+ if (activeSessions.length > maxSessions) {
+ const toDelete = activeSessions.slice(0, activeSessions.length - maxSessions);
+ await prisma.activeSession.deleteMany({
+ where: { id: { in: toDelete.map((s) => s.id) } },
+ });
+ logger.info({ userId: user.id, kicked: toDelete.length, maxSessions }, "Kicked oldest sessions");
+ }
+ } catch (err) {
+ // Non-blocking: don't prevent login if session tracking fails
+ logger.error({ err }, "Failed to enforce concurrent session limit");
+ }
}
return token;
},
},
+ events: {
+ async signOut(message) {
+ // Auth.js fires this event on sign-out; extract userId from the JWT token
+ const token = "token" in message ? message.token : null;
+ const userId = token?.sub ?? null;
+ const email = token?.email ?? "unknown";
+ const jti = token?.jti as string | undefined;
+
+ // Remove from active session registry
+ if (jti) {
+ void prisma.activeSession.delete({ where: { jti } }).catch(() => { /* already gone */ });
+ }
+
+ void createAuditEntry({
+ db: prisma,
+ entityType: "Auth",
+ entityId: userId ?? email,
+ entityName: email,
+ action: "DELETE",
+ ...(userId ? { userId } : {}),
+ summary: "User logged out",
+ source: "ui",
+ });
+ },
+ },
+ cookies: {
+ sessionToken: {
+ name: "authjs.session-token",
+ options: {
+ httpOnly: true,
+ sameSite: "strict" as const,
+ path: "/",
+ secure: process.env.NODE_ENV === "production",
+ },
+ },
+ callbackUrl: {
+ name: "authjs.callback-url",
+ options: {
+ httpOnly: true,
+ sameSite: "strict" as const,
+ path: "/",
+ secure: process.env.NODE_ENV === "production",
+ },
+ },
+ csrfToken: {
+ name: "authjs.csrf-token",
+ options: {
+ httpOnly: true,
+ sameSite: "strict" as const,
+ path: "/",
+ secure: process.env.NODE_ENV === "production",
+ },
+ },
+ },
pages: {
signIn: "/auth/signin",
},
session: {
strategy: "jwt",
+ maxAge: 28800, // 8 hours absolute timeout
+ updateAge: 1800, // Refresh token every 30 minutes (idle timeout)
},
} satisfies NextAuthConfig;
diff --git a/docs/sdlc.md b/docs/sdlc.md
new file mode 100644
index 0000000..f8b61bb
--- /dev/null
+++ b/docs/sdlc.md
@@ -0,0 +1,57 @@
+# Secure Development Lifecycle (SDLC) — CapaKraken
+
+> Version: 1.0 | Date: 2026-03-27
+
+---
+
+## Development Workflow
+
+```
+Feature Branch -> Pull Request -> CI Pipeline -> Code Review -> Merge to main -> Deploy
+```
+
+## CI Pipeline (Quality Gates)
+
+Every pull request must pass:
+
+1. **TypeScript strict check**: `pnpm --filter @capakraken/web exec tsc --noEmit`
+2. **Linting**: `pnpm lint` (ESLint with strict rules)
+3. **Unit tests**: `pnpm test:unit` (Vitest, engine + staffing packages)
+4. **E2E tests**: Playwright tests for critical user flows
+
+## Security Gates
+
+| Gate | Tool | Stage |
+|------|------|-------|
+| Type safety | TypeScript strict mode | Build |
+| Input validation | Zod schemas on all tRPC procedures | Build + Runtime |
+| Dependency vulnerabilities | Dependabot + `pnpm audit` | PR + Weekly |
+| Audit logging | `createAuditEntry()` required for data mutations | Code review |
+| RBAC enforcement | `requirePermission()` on new procedures | Code review |
+| No hardcoded secrets | PR review checklist | Code review |
+| SQL injection prevention | Prisma ORM (parameterized queries only) | Architecture |
+
+## PR Review Checklist
+
+See `.github/PULL_REQUEST_TEMPLATE.md` for the security checklist that must be completed on every PR.
+
+## Branch Protection
+
+- Direct pushes to `main` are blocked
+- Minimum 1 approval required
+- CI must pass before merge
+- Force-pushes to `main` are prohibited
+
+## Secret Management
+
+- No secrets in source code
+- Environment variables for all credentials (`DATABASE_URL`, API keys)
+- `SystemSettings` table for runtime-configurable secrets (AI keys, SMTP credentials)
+- `.env` files excluded from version control via `.gitignore`
+
+## Incident Response
+
+1. Identify and contain the issue
+2. Create audit log review for affected timeframe
+3. Patch and deploy fix
+4. Post-mortem documented in `LEARNINGS.md`
diff --git a/docs/security-architecture.md b/docs/security-architecture.md
new file mode 100644
index 0000000..48496b2
--- /dev/null
+++ b/docs/security-architecture.md
@@ -0,0 +1,158 @@
+# Security Architecture — CapaKraken
+
+> Version: 1.0 | Date: 2026-03-27
+
+---
+
+## 1. Authentication
+
+- **Auth.js v5** (NextAuth) with Credentials provider
+- **Password hashing**: Argon2id via `@node-rs/argon2` (memory cost 65536, time cost 3)
+- **Multi-Factor Authentication**: TOTP (RFC 6238) via `otpauth` library
+ - Configurable per user (enable/disable via admin or self-service)
+ - 30-second window, SHA-1, 6-digit codes with 1-step tolerance
+- **Rate limiting**: 5 login attempts per 15 minutes per email address (in-memory sliding window)
+- **Session strategy**: JWT with server-side validation
+ - Absolute timeout: 8 hours (configurable via `sessionMaxAge`)
+ - Idle timeout: 30 minutes (configurable via `sessionIdleTimeout`)
+- **Concurrent session limit**: configurable `maxConcurrentSessions` (default 3), kick-oldest strategy
+- **Login/logout audit**: all authentication events (success, failure, rate-limit, invalid TOTP, logout) are recorded in the audit log
+
+## 2. Authorization
+
+### Role-Based Access Control (RBAC)
+
+Five-level role hierarchy:
+
+| Role | Level | Capabilities |
+|------|-------|-------------|
+| ADMIN | 5 | Full system access, user management, system settings |
+| MANAGER | 4 | Project management, resource allocation, vacation approval |
+| CONTROLLER | 3 | Financial views, budget management, reporting |
+| USER | 2 | Self-service (own vacations, own resource profile) |
+| VIEWER | 1 | Read-only access to permitted areas |
+
+### Per-User Permission Overrides
+
+- `permissionOverrides` JSONB field on User model
+- `resolvePermissions(role, overrides)` computes effective permissions
+- `requirePermission(ctx, key)` enforced on every tRPC procedure
+- Granular `PermissionKey` enum covering all domain actions
+
+### tRPC Middleware Stack
+
+```
+publicProcedure
+ -> protectedProcedure (requires authenticated session)
+ -> controllerProcedure (ADMIN + MANAGER + CONTROLLER)
+ -> managerProcedure (ADMIN + MANAGER)
+ -> adminProcedure (ADMIN only)
+```
+
+## 3. Data Protection
+
+### Database Security
+
+- **PostgreSQL** with TLS in production
+- **Prisma ORM**: parameterized queries by default — no SQL injection risk
+- Database not exposed to the internet (Docker internal network only)
+- All monetary values stored as integer cents (no floating-point precision issues)
+
+### Data at Rest
+
+- Passwords: Argon2id hash (never stored in plaintext)
+- TOTP secrets: stored in DB (encrypted at-rest via PostgreSQL TDE when available)
+- API keys (Azure OpenAI, Gemini, SMTP): stored in `SystemSettings` table, accessible only to ADMIN role
+
+### Anonymization
+
+- Configurable global anonymization for VIEWER role
+- Resource names, emails replaced with deterministic pseudonyms (seeded hash)
+- Anonymization domain and mode configurable in SystemSettings
+
+## 4. Session Management
+
+- **Server-side JWT** with `SameSite=Strict` cookies
+- `httpOnly` cookies prevent XSS-based session theft
+- `secure` flag enforced in production (HTTPS only)
+- CSRF protection via Auth.js built-in CSRF token
+- Configurable session timeouts (absolute + idle) via SystemSettings
+- Active session registry with concurrent session limit enforcement
+
+## 5. Input Validation
+
+- **Zod schemas** on every tRPC procedure input
+- Strict TypeScript (`strict: true`, `exactOptionalPropertyTypes: true`)
+- Blueprint dynamic fields validated at runtime against stored Zod schema definitions
+- File uploads validated by:
+ - MIME type whitelist (`image/png`, `image/jpeg`, `image/webp`, `image/tiff`, `image/bmp`)
+ - Size limit (10 MB client-side, 4 MB server-side after compression)
+ - Magic byte verification (actual file content matched against declared MIME)
+
+## 6. Audit Logging
+
+### Activity History System
+
+- Centralized `createAuditEntry()` function (fire-and-forget, never blocks)
+- Covers 29+ of 36 tRPC routers
+- Logged fields: `entityType`, `entityId`, `action`, `userId`, `changes` (JSONB with before/after/diff), `source`, `summary`
+- Authentication events: login success/failure, logout, rate limiting, MFA failures
+
+### External API Call Logging
+
+- All OpenAI/Azure/Gemini API calls logged via `loggedAiCall()` wrapper
+- Structured Pino logs: `{ provider, model, promptLength, responseTimeMs }`
+- Failed calls logged at `warn` level with error details
+
+### tRPC Request Logging
+
+- Every tRPC call logged with request ID, user ID, path, duration
+- Slow calls (>500ms) logged at `warn` level
+
+## 7. HTTP Security Headers
+
+Configured in `next.config.ts`:
+
+| Header | Value |
+|--------|-------|
+| Strict-Transport-Security | `max-age=63072000; includeSubDomains; preload` |
+| Content-Security-Policy | Restrictive CSP with nonce-based script-src |
+| X-Frame-Options | `DENY` |
+| X-Content-Type-Options | `nosniff` |
+| X-XSS-Protection | `1; mode=block` |
+| Referrer-Policy | `strict-origin-when-cross-origin` |
+| Permissions-Policy | Camera, microphone, geolocation disabled |
+
+## 8. Rate Limiting
+
+- **Per-IP rate limiting**: via middleware on all API routes
+- **Per-user rate limiting**: configurable per-procedure
+- **Auth-specific rate limiting**: 5 attempts / 15 min per email (in-memory sliding window)
+- **AI API call rate limits**: upstream provider limits surfaced as user-friendly errors
+
+## 9. Error Handling
+
+- **Sentry** integration for production error tracking
+- **Pino** structured logging (JSON in production, pretty-print in development)
+- tRPC errors mapped to appropriate HTTP status codes
+- AI API errors translated to human-readable messages via `parseAiError()` / `parseGeminiError()`
+- Internal errors never leak stack traces to the client
+
+## 10. Dependency Security
+
+- **Dependabot** configured for automated dependency updates
+- `pnpm audit` as part of CI pipeline
+- Lockfile integrity verified on install
+
+## 11. Network Architecture
+
+```
+Browser -> Next.js (port 3100) -> tRPC -> Prisma -> PostgreSQL (port 5433)
+ -> Redis (port 6380, SSE pub/sub)
+ -> Azure OpenAI / Gemini (external HTTPS)
+ -> SMTP (email notifications)
+```
+
+- PostgreSQL and Redis accessible only within Docker network
+- External API calls (AI, SMTP) over TLS
+- No direct database access from the internet
diff --git a/packages/api/package.json b/packages/api/package.json
index c8c6417..e5acab4 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -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"
},
diff --git a/packages/api/src/ai-client.ts b/packages/api/src/ai-client.ts
index 79fdc8c..4d79c4f 100644
--- a/packages/api/src/ai-client.ts
+++ b/packages/api/src/ai-client.ts
@@ -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(
+ provider: string,
+ model: string,
+ promptLength: number,
+ fn: () => Promise,
+): Promise {
+ 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);
diff --git a/packages/api/src/gemini-client.ts b/packages/api/src/gemini-client.ts
index d84757a..25a550e 100644
--- a/packages/api/src/gemini-client.ts
+++ b/packages/api/src/gemini-client.ts
@@ -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 {
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}`;
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index 16fdccb..8f85e4b 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -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";
diff --git a/packages/api/src/lib/image-validation.ts b/packages/api/src/lib/image-validation.ts
new file mode 100644
index 0000000..ac675f3
--- /dev/null
+++ b/packages/api/src/lib/image-validation.ts
@@ -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 };
+}
diff --git a/packages/api/src/middleware/logging.ts b/packages/api/src/middleware/logging.ts
index 2568c48..e03222c 100644
--- a/packages/api/src/middleware/logging.ts
+++ b/packages/api/src/middleware/logging.ts
@@ -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;
}
diff --git a/packages/api/src/middleware/rate-limit.ts b/packages/api/src/middleware/rate-limit.ts
new file mode 100644
index 0000000..5b2c61d
--- /dev/null
+++ b/packages/api/src/middleware/rate-limit.ts
@@ -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();
+
+ // 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);
diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts
index e73495e..38b0c12 100644
--- a/packages/api/src/router/assistant-tools.ts
+++ b/packages/api/src/router/assistant-tools.ts
@@ -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." };
diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts
index f197c64..83ff821 100644
--- a/packages/api/src/router/assistant.ts
+++ b/packages/api/src/router/assistant.ts
@@ -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",
diff --git a/packages/api/src/router/insights.ts b/packages/api/src/router/insights.ts
index 2116fe1..160978a 100644
--- a/packages/api/src/router/insights.ts
+++ b/packages/api/src/router/insights.ts
@@ -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({
diff --git a/packages/api/src/router/project.ts b/packages/api/src/router/project.ts
index 3d4e860..1f474a1 100644
--- a/packages/api/src/router/project.ts
+++ b/packages/api/src/router/project.ts
@@ -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",
diff --git a/packages/api/src/router/resource.ts b/packages/api/src/router/resource.ts
index 0acb99a..0d75664 100644
--- a/packages/api/src/router/resource.ts
+++ b/packages/api/src/router/resource.ts
@@ -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 = "";
diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts
index 94d4349..9066e19 100644
--- a/packages/api/src/router/user.ts
+++ b/packages/api/src/router/user.ts
@@ -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 };
+ }),
});
diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts
index 05d2bca..32196aa 100644
--- a/packages/api/src/trpc.ts
+++ b/packages/api/src/trpc.ts
@@ -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,
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index c2c8278..90e0c93 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -179,6 +179,8 @@ model User {
favoriteProjectIds Json? @db.JsonB // string[] of project IDs
lastLoginAt DateTime?
lastActiveAt DateTime?
+ totpSecret String? // Base32 TOTP secret
+ totpEnabled Boolean @default(false)
accounts Account[]
sessions Session[]
@@ -191,6 +193,7 @@ model User {
notificationsSent Notification[] @relation("notificationSender")
broadcasts NotificationBroadcast[] @relation("broadcastSender")
comments Comment[]
+ activeSessions ActiveSession[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1453,11 +1456,33 @@ model SystemSettings {
geminiApiKey String?
geminiModel String? @default("gemini-2.5-flash-image")
imageProvider String? @default("dalle") // "dalle" | "gemini"
+ // Session timeout settings
+ sessionMaxAge Int? @default(28800) // Absolute timeout in seconds (8h)
+ sessionIdleTimeout Int? @default(1800) // Idle timeout in seconds (30min)
+ // Concurrent session limit (kick-oldest strategy)
+ maxConcurrentSessions Int? @default(3)
updatedAt DateTime @updatedAt
@@map("system_settings")
}
+// ─── Active Session Registry (JWT session tracking) ──────────────────────────
+
+model ActiveSession {
+ id String @id @default(cuid())
+ userId String
+ jti String @unique // JWT ID — unique per token
+ createdAt DateTime @default(now())
+ lastSeenAt DateTime @default(now())
+ userAgent String?
+ ipAddress String?
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId, createdAt])
+ @@map("active_sessions")
+}
+
// ─── Calculation Rules ────────────────────────────────────────────────────────
model CalculationRule {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6cb13ae..b578047 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -84,6 +84,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ dompurify:
+ specifier: ^3.3.3
+ version: 3.3.3
framer-motion:
specifier: ^12.38.0
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -93,6 +96,9 @@ importers:
next-auth:
specifier: ^5.0.0-beta.25
version: 5.0.0-beta.30(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
+ otpauth:
+ specifier: ^9.5.0
+ version: 9.5.0
react:
specifier: ^19.0.0
version: 19.2.4
@@ -130,6 +136,9 @@ importers:
'@playwright/test':
specifier: ^1.49.1
version: 1.58.2
+ '@types/dompurify':
+ specifier: ^3.2.0
+ version: 3.2.0
'@types/node':
specifier: ^22.10.2
version: 22.19.13
@@ -193,6 +202,9 @@ importers:
openai:
specifier: ^6.27.0
version: 6.27.0(zod@3.25.76)
+ otpauth:
+ specifier: ^9.5.0
+ version: 9.5.0
pino:
specifier: ^10.3.1
version: 10.3.1
@@ -1083,6 +1095,10 @@ packages:
cpu: [x64]
os: [win32]
+ '@noble/hashes@2.0.1':
+ resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
+ engines: {node: '>= 20.19.0'}
+
'@node-rs/argon2-android-arm-eabi@2.0.2':
resolution: {integrity: sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==}
engines: {node: '>= 10'}
@@ -1858,6 +1874,10 @@ packages:
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
+ '@types/dompurify@3.2.0':
+ resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
+ deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
+
'@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -1912,6 +1932,9 @@ packages:
'@types/three@0.183.1':
resolution: {integrity: sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==}
+ '@types/trusted-types@2.0.7':
+ resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
@@ -2553,6 +2576,9 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
+ dompurify@3.3.3:
+ resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==}
+
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
@@ -3515,6 +3541,9 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
+ otpauth@9.5.0:
+ resolution: {integrity: sha512-Ldhc6UYl4baR5toGr8nfKC+L/b8/RgHKoIixAebgoNGzUUCET02g04rMEZ2ZsPfeVQhMHcuaOgb28nwMr81zCA==}
+
own-keys@1.0.1:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
@@ -5035,6 +5064,8 @@ snapshots:
'@next/swc-win32-x64-msvc@15.5.12':
optional: true
+ '@noble/hashes@2.0.1': {}
+
'@node-rs/argon2-android-arm-eabi@2.0.2':
optional: true
@@ -5894,6 +5925,10 @@ snapshots:
'@types/d3-timer@3.0.2': {}
+ '@types/dompurify@3.2.0':
+ dependencies:
+ dompurify: 3.3.3
+
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 9.6.1
@@ -5965,6 +6000,9 @@ snapshots:
fflate: 0.8.2
meshoptimizer: 1.0.1
+ '@types/trusted-types@2.0.7':
+ optional: true
+
'@types/use-sync-external-store@0.0.6': {}
'@types/webxr@0.5.24': {}
@@ -6680,6 +6718,10 @@ snapshots:
dependencies:
esutils: 2.0.3
+ dompurify@3.3.3:
+ optionalDependencies:
+ '@types/trusted-types': 2.0.7
+
dotenv@16.6.1: {}
dunder-proto@1.0.1:
@@ -7732,6 +7774,10 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
+ otpauth@9.5.0:
+ dependencies:
+ '@noble/hashes': 2.0.1
+
own-keys@1.0.1:
dependencies:
get-intrinsic: 1.3.0