feat: enhance security with token blacklisting and input sanitization; add helmet for security headers
Test / backend (push) Successful in 15s
Test / frontend (push) Successful in 24s

This commit is contained in:
LaurensBartelds committed 2026-04-27 12:52:57 +02:00
1 parent 4935251125
commit 1b5fd04040
25 files changed
+294 -90

No files matched your search

+1 -3
View File
@@ -27,10 +27,8 @@ jobs:
echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV"
- name: Write Inventory
# StrictHostKeyChecking=no is intentional for a known home-network host.
# To harden, add the host fingerprint as a secret and use a known_hosts file.
run: |
printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s ansible_ssh_common_args="-o StrictHostKeyChecking=no -o ForwardAgent=yes"\n' \
printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s\n' \
"${{ secrets.ANSIBLE_HOST }}" \
"${{ secrets.ANSIBLE_USER }}" > /tmp/inventory.ini
+10
View File
@@ -14,6 +14,7 @@
"dotenv": "^16.4.7",
"express": "^5.1.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^7.0.3",
"zod": "^3.24.2"
@@ -1862,6 +1863,15 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+1
View File
@@ -16,6 +16,7 @@
"dotenv": "^16.4.7",
"express": "^5.1.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^7.0.3",
"zod": "^3.24.2"
+1 -1
View File
@@ -28,7 +28,7 @@ export const config = {
jwtSecret: env('JWT_SECRET'),
dbPath: env('DB_PATH', './db/brainwave.db'),
corsOrigin: env('CORS_ORIGIN', 'http://localhost:5173'),
bodyLimit: env('BODY_LIMIT', '10mb'),
bodyLimit: env('BODY_LIMIT', '100kb'),
email: {
smtpHost: env('SMTP_HOST', ''),
+18 -1
View File
@@ -1,6 +1,8 @@
import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import { getDb, closeDb } from './db/database.js'
import authRoutes from './routes/auth.js'
import examRoutes from './routes/exams.js'
@@ -20,9 +22,25 @@ const PORT = config.port
const app = express()
// Security headers
app.use(helmet())
// CORS
app.use(cors({ origin: config.corsOrigin }))
// Body parsing with reasonable limit
app.use(express.json({ limit: config.bodyLimit }))
// Global API rate limiting
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 200,
message: { error: 'Too many requests, please try again later' },
standardHeaders: true,
legacyHeaders: false,
})
app.use('/api/', globalLimiter)
// Request logging
app.use((req, res, next) => {
const start = Date.now()
@@ -63,7 +81,6 @@ app.get('/api/health', (_req, res) => {
res.status(overall === 'healthy' ? 200 : 503).json({
status: overall,
timestamp: Date.now(),
version: process.env['npm_package_version'] ?? '0.0.1',
checks,
})
})
+9 -1
View File
@@ -1,5 +1,6 @@
import type { Request, Response, NextFunction } from 'express'
import { verifyToken, type JwtPayload } from '../utils/tokens.js'
import { verifyToken, hashToken, type JwtPayload } from '../utils/tokens.js'
import { isTokenBlacklisted } from '../utils/tokenBlacklist.js'
declare global {
namespace Express {
@@ -22,6 +23,13 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
try {
const token = header.slice(7)
req.user = verifyToken(token)
// Check if the token has been blacklisted (e.g., after logout)
if (isTokenBlacklisted(hashToken(token))) {
res.status(401).json({ error: 'Token has been revoked' })
return
}
next()
} catch {
res.status(401).json({ error: 'Invalid or expired token' })
+11 -5
View File
@@ -17,15 +17,21 @@ export function errorHandler(
_next: any
) {
const statusCode = err instanceof AppError ? err.statusCode : 500;
const message = err.message || 'Internal Server Error';
const isOperational = err instanceof AppError && err.isOperational;
console.error(`[Error] ${statusCode} - ${message}`);
if (!(err instanceof AppError) || !err.isOperational) {
// Log full details server-side
console.error(`[Error] ${statusCode} - ${err.message}`);
if (!isOperational) {
console.error(err);
}
// Never leak internal error details to the client for unexpected 500s
const clientMessage = statusCode === 500 && !isOperational
? 'Internal Server Error'
: (err.message || 'Internal Server Error');
res.status(statusCode).json({
status: 'error',
message,
message: clientMessage,
});
}
}
+4 -4
View File
@@ -63,14 +63,14 @@ export function getUserById(id: number): DbUser | undefined {
return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DbUser | undefined
}
export function getUserByVerifyToken(token: string): DbUser | undefined {
export function getUserByVerifyTokenHash(tokenHash: string): DbUser | undefined {
const db = getDb()
return db.prepare('SELECT * FROM users WHERE verify_token = ?').get(token) as DbUser | undefined
return db.prepare('SELECT * FROM users WHERE verify_token = ?').get(tokenHash) as DbUser | undefined
}
export function getUserByResetToken(token: string): DbUser | undefined {
export function getUserByResetTokenHash(tokenHash: string): DbUser | undefined {
const db = getDb()
return db.prepare('SELECT * FROM users WHERE reset_token = ?').get(token) as DbUser | undefined
return db.prepare('SELECT * FROM users WHERE reset_token = ?').get(tokenHash) as DbUser | undefined
}
export function updateUser(id: number, data: UpdateUserData): boolean {
+19
View File
@@ -5,6 +5,8 @@ import { requireAuth } from '../middleware/auth.js'
import { asyncHandler } from '../utils/asyncHandler.js'
import * as authService from '../services/authService.js'
import * as userRepo from '../repositories/userRepo.js'
import { hashToken } from '../utils/tokens.js'
import { blacklistToken } from '../utils/tokenBlacklist.js'
import {
registerSchema,
loginSchema,
@@ -113,6 +115,23 @@ router.post(
})
)
// POST /api/auth/logout
router.post(
'/logout',
requireAuth,
asyncHandler(async (req: Request, res) => {
const header = req.headers.authorization
if (header?.startsWith('Bearer ')) {
const token = header.slice(7)
const tokenHash = hashToken(token)
// Blacklist the token so it cannot be reused after logout
// Expiry is 24h from now (matching JWT expiry)
blacklistToken(tokenHash, Date.now() + 24 * 60 * 60 * 1000)
}
res.json({ success: true })
})
)
// DELETE /api/auth/me
router.delete(
'/me',
+3 -2
View File
@@ -5,6 +5,7 @@ import { requireAuth } from '../middleware/auth.js'
import { AppError } from '../middleware/error.js'
import * as examRepo from '../repositories/examRepo.js'
import { saveExamSchema, updateExamSchema, examIdParamSchema } from '../validators/examSchemas.js'
import { sanitizeExam } from '../utils/sanitizeExam.js'
import type { Request } from 'express'
const router = Router()
@@ -41,7 +42,7 @@ router.post(
'/',
validateBody(saveExamSchema),
asyncHandler(async (req: Request, res) => {
const exam = req.body.exam
const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
const { id } = examRepo.createExam(
req.user!.userId,
exam.title,
@@ -59,7 +60,7 @@ router.put(
validateBody(updateExamSchema),
asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id)
const exam = req.body.exam
const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
const updated = examRepo.updateExam(
id,
req.user!.userId,
+54 -42
View File
@@ -3,6 +3,7 @@ import bcrypt from 'bcrypt'
import { validateBody } from '../middleware/validate.js'
import { asyncHandler } from '../utils/asyncHandler.js'
import { AppError } from '../middleware/error.js'
import { getDb } from '../db/database.js'
import * as userRepo from '../repositories/userRepo.js'
import * as settingsRepo from '../repositories/settingsRepo.js'
import { setupSchema } from '../validators/setupSchemas.js'
@@ -33,54 +34,65 @@ router.post(
'/',
validateBody(setupSchema),
asyncHandler(async (req, res) => {
if (getSiteTitle()) {
throw new AppError(400, 'Setup already completed')
}
const db = getDb()
if (userRepo.countUsers() > 0) {
throw new AppError(400, 'Users already exist')
}
// Atomic transaction prevents race conditions between concurrent setup requests
const tx = db.transaction(() => {
const title = settingsRepo.getSetting('site_title')
if (title) {
throw new AppError(400, 'Setup already completed')
}
const {
email,
password,
displayName,
siteTitle,
language,
smtpHost,
smtpPort,
smtpUser,
smtpPass,
smtpFrom,
smtpSecure,
appUrl,
} = req.body
const userCount = userRepo.countUsers()
if (userCount > 0) {
throw new AppError(400, 'Users already exist')
}
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS)
const {
email,
password,
displayName,
siteTitle,
language,
smtpHost,
smtpPort,
smtpUser,
smtpPass,
smtpFrom,
smtpSecure,
appUrl,
} = req.body
const { id } = userRepo.createUser({
email,
passwordHash,
displayName,
role: 'admin',
emailVerified: 1,
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
const { id } = userRepo.createUser({
email,
passwordHash,
displayName,
role: 'admin',
emailVerified: 1,
})
const settings: Array<[string, string]> = [
['site_title', siteTitle?.trim() || 'BrainWave'],
['site_language', language || 'en'],
['maintenance_mode', 'false'],
]
if (smtpHost !== undefined) settings.push(['smtp_host', smtpHost])
if (smtpPort !== undefined) settings.push(['smtp_port', smtpPort])
if (smtpUser !== undefined) settings.push(['smtp_user', smtpUser])
if (smtpPass !== undefined) settings.push(['smtp_pass', smtpPass])
if (smtpFrom !== undefined) settings.push(['smtp_from', smtpFrom])
if (smtpSecure !== undefined) settings.push(['smtp_secure', smtpSecure])
if (appUrl !== undefined) settings.push(['app_url', appUrl])
settingsRepo.setSettingsBatch(settings)
return { id, email, displayName }
})
const settings: Array<[string, string]> = [
['site_title', siteTitle?.trim() || 'BrainWave'],
['site_language', language || 'en'],
['maintenance_mode', 'false'],
]
if (smtpHost !== undefined) settings.push(['smtp_host', smtpHost])
if (smtpPort !== undefined) settings.push(['smtp_port', smtpPort])
if (smtpUser !== undefined) settings.push(['smtp_user', smtpUser])
if (smtpPass !== undefined) settings.push(['smtp_pass', smtpPass])
if (smtpFrom !== undefined) settings.push(['smtp_from', smtpFrom])
if (smtpSecure !== undefined) settings.push(['smtp_secure', smtpSecure])
if (appUrl !== undefined) settings.push(['app_url', appUrl])
settingsRepo.setSettingsBatch(settings)
const { id, email, displayName } = tx()
const token = signToken({
userId: id,
+17 -9
View File
@@ -1,6 +1,6 @@
import bcrypt from 'bcrypt'
import * as userRepo from '../repositories/userRepo.js'
import { signToken, generateVerificationToken } from '../utils/tokens.js'
import { signToken, generateVerificationToken, hashToken } from '../utils/tokens.js'
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
import { AppError } from '../middleware/error.js'
import type { RegisterInput, LoginInput, UpdateProfileInput, ChangePasswordInput, VerifyEmailInput, ResetPasswordInput } from '../validators/authSchemas.js'
@@ -22,7 +22,8 @@ export async function register(input: RegisterInput, isFirstUser: boolean): Prom
const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS)
const role = isFirstUser ? 'admin' : 'user'
const verifyToken = generateVerificationToken()
const rawVerifyToken = generateVerificationToken()
const verifyTokenHash = hashToken(rawVerifyToken)
const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS
const { id } = userRepo.createUser({
@@ -31,14 +32,14 @@ export async function register(input: RegisterInput, isFirstUser: boolean): Prom
displayName: input.displayName,
role,
emailVerified: 0,
verifyToken,
verifyToken: verifyTokenHash,
verifyExpires,
})
const user = userRepo.getUserById(id)!
// Don't await — fire and forget so registration isn't blocked by email failures
sendVerificationEmail(user.email, verifyToken).catch(() => {})
sendVerificationEmail(user.email, rawVerifyToken).catch(() => {})
return {
token: signToken({
@@ -67,6 +68,10 @@ export async function login(input: LoginInput): Promise<AuthResult> {
throw new AppError(401, 'Invalid email or password')
}
if (!user.email_verified) {
throw new AppError(403, 'Please verify your email before logging in')
}
return {
token: signToken({
userId: user.id,
@@ -118,7 +123,8 @@ export async function changePassword(userId: number, input: ChangePasswordInput)
}
export async function verifyEmail(input: VerifyEmailInput) {
const user = userRepo.getUserByVerifyToken(input.token)
const tokenHash = hashToken(input.token)
const user = userRepo.getUserByVerifyTokenHash(tokenHash)
if (!user || !user.verify_expires || Date.now() > user.verify_expires) {
throw new AppError(400, 'Invalid or expired verification token')
}
@@ -141,17 +147,19 @@ export async function forgotPassword(email?: string) {
return
}
const resetToken = generateVerificationToken()
const rawResetToken = generateVerificationToken()
const resetTokenHash = hashToken(rawResetToken)
const resetExpires = Date.now() + RESET_TOKEN_EXPIRY_MS
userRepo.updateUser(user.id, { resetToken, resetExpires })
userRepo.updateUser(user.id, { resetToken: resetTokenHash, resetExpires })
// Fire and forget
sendPasswordResetEmail(user.email, resetToken).catch(() => {})
sendPasswordResetEmail(user.email, rawResetToken).catch(() => {})
}
export async function resetPassword(input: ResetPasswordInput) {
const user = userRepo.getUserByResetToken(input.token)
const tokenHash = hashToken(input.token)
const user = userRepo.getUserByResetTokenHash(tokenHash)
if (!user || !user.reset_expires || Date.now() > user.reset_expires) {
throw new AppError(400, 'Invalid or expired reset token')
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Server-side sanitization for user-provided exam content.
* Removes script tags and event handlers from string fields.
*/
function sanitizeString(value: unknown): unknown {
if (typeof value !== 'string') return value
return value
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<script[^>]*\/>/gi, '')
.replace(/javascript:/gi, '')
.replace(/on\w+\s*=/gi, '')
}
function sanitizeObject(obj: unknown): unknown {
if (typeof obj === 'string') return sanitizeString(obj)
if (Array.isArray(obj)) return obj.map(sanitizeObject)
if (obj && typeof obj === 'object') {
const result: Record<string, unknown> = {}
for (const [key, val] of Object.entries(obj)) {
result[key] = sanitizeObject(val)
}
return result
}
return obj
}
export function sanitizeExam(exam: Record<string, unknown>): Record<string, unknown> {
return sanitizeObject(exam) as Record<string, unknown>
}
+46
View File
@@ -0,0 +1,46 @@
/**
* In-memory token blacklist with SQLite persistence.
* Tokens are blacklisted on logout and expire automatically based on JWT expiry.
*/
import { getDb } from '../db/database.js'
const memoryCache = new Set<string>()
let initialized = false
function initCache(): void {
if (initialized) return
const db = getDb()
db.exec(`
CREATE TABLE IF NOT EXISTS token_blacklist (
token_hash TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_token_blacklist_expires ON token_blacklist(expires_at);
`)
// Load recent blacklisted tokens into memory
const rows = db.prepare('SELECT token_hash FROM token_blacklist WHERE expires_at > ?')
.all(Date.now()) as { token_hash: string }[]
for (const row of rows) {
memoryCache.add(row.token_hash)
}
initialized = true
}
export function isTokenBlacklisted(tokenHash: string): boolean {
initCache()
return memoryCache.has(tokenHash)
}
export function blacklistToken(tokenHash: string, expiresAt: number): void {
initCache()
memoryCache.add(tokenHash)
const db = getDb()
db.prepare('INSERT OR IGNORE INTO token_blacklist (token_hash, expires_at) VALUES (?, ?)')
.run(tokenHash, expiresAt)
}
export function cleanupExpiredTokens(): void {
const db = getDb()
db.prepare('DELETE FROM token_blacklist WHERE expires_at <= ?').run(Date.now())
}
+4
View File
@@ -20,3 +20,7 @@ export function verifyToken(token: string): JwtPayload {
export function generateVerificationToken(): string {
return crypto.randomBytes(32).toString('hex')
}
export function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex')
}
+2 -2
View File
@@ -20,7 +20,7 @@ describe('updateUserRoleSchema', () => {
describe('updateSettingSchema', () => {
it('accepts valid setting', () => {
const result = updateSettingSchema.safeParse({ key: 'app_name', value: 'BrainWave' })
const result = updateSettingSchema.safeParse({ key: 'site_title', value: 'BrainWave' })
expect(result.success).toBe(true)
})
@@ -30,7 +30,7 @@ describe('updateSettingSchema', () => {
})
it('rejects empty value', () => {
const result = updateSettingSchema.safeParse({ key: 'app_name', value: '' })
const result = updateSettingSchema.safeParse({ key: 'site_title', value: '' })
expect(result.success).toBe(false)
})
})
+19 -1
View File
@@ -5,8 +5,26 @@ export const updateUserRoleSchema = z.object({
role: z.enum(['user', 'admin']),
})
const ALLOWED_SETTING_KEYS = [
'site_title',
'site_language',
'maintenance_mode',
'maintenance_message',
'allow_guest_mode',
'registration_enabled',
'default_theme',
'smtp_host',
'smtp_port',
'smtp_user',
'smtp_pass',
'smtp_from',
'smtp_secure',
'app_url',
'max_upload_mb',
] as const
export const updateSettingSchema = z.object({
key: z.string().min(1),
key: z.enum(ALLOWED_SETTING_KEYS),
value: z.string().min(1),
})
+5 -5
View File
@@ -5,7 +5,7 @@ describe('registerSchema', () => {
it('accepts valid registration', () => {
const result = registerSchema.safeParse({
email: 'test@example.com',
password: 'password123',
password: 'Password123!',
displayName: 'Test User',
})
expect(result.success).toBe(true)
@@ -14,7 +14,7 @@ describe('registerSchema', () => {
it('rejects invalid email', () => {
const result = registerSchema.safeParse({
email: 'not-an-email',
password: 'password123',
password: 'Password123!',
displayName: 'Test User',
})
expect(result.success).toBe(false)
@@ -32,7 +32,7 @@ describe('registerSchema', () => {
it('rejects empty display name', () => {
const result = registerSchema.safeParse({
email: 'test@example.com',
password: 'password123',
password: 'Password123!',
displayName: '',
})
expect(result.success).toBe(false)
@@ -65,7 +65,7 @@ describe('verifyEmailSchema', () => {
describe('resetPasswordSchema', () => {
it('accepts valid reset', () => {
const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'newpass123' })
const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'NewPass123!' })
expect(result.success).toBe(true)
})
@@ -91,7 +91,7 @@ describe('changePasswordSchema', () => {
it('accepts valid change', () => {
const result = changePasswordSchema.safeParse({
currentPassword: 'oldpass',
newPassword: 'newpass123',
newPassword: 'NewPass123!',
})
expect(result.success).toBe(true)
})
+11 -3
View File
@@ -1,8 +1,12 @@
import { z } from 'zod'
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
export const registerSchema = z.object({
email: z.string().email().max(255),
password: z.string().min(8, 'Password must be at least 8 characters'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
displayName: z.string().min(1, 'Display name is required').max(100),
})
@@ -17,7 +21,9 @@ export const verifyEmailSchema = z.object({
export const resetPasswordSchema = z.object({
token: z.string().min(1, 'Token is required'),
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
newPassword: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
})
export const updateProfileSchema = z.object({
@@ -26,7 +32,9 @@ export const updateProfileSchema = z.object({
export const changePasswordSchema = z.object({
currentPassword: z.string(),
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
newPassword: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
})
export const forgotPasswordSchema = z.object({
+2 -2
View File
@@ -5,7 +5,7 @@ describe('setupSchema', () => {
it('accepts valid setup', () => {
const result = setupSchema.safeParse({
email: 'admin@example.com',
password: 'adminpass123',
password: 'AdminPass123!',
displayName: 'Admin',
siteTitle: 'BrainWave',
language: 'en',
@@ -16,7 +16,7 @@ describe('setupSchema', () => {
it('rejects invalid email', () => {
const result = setupSchema.safeParse({
email: 'bad',
password: 'adminpass123',
password: 'AdminPass123!',
displayName: 'Admin',
})
expect(result.success).toBe(false)
+5 -1
View File
@@ -1,8 +1,12 @@
import { z } from 'zod'
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
export const setupSchema = z.object({
email: z.string().email().max(255),
password: z.string().min(8, 'Password must be at least 8 characters'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
displayName: z.string().min(1, 'Display name is required').max(100),
siteTitle: z.string().min(1, 'Site title is required').max(255).optional(),
language: z.enum(['en', 'nl']).optional(),
-6
View File
@@ -1,6 +0,0 @@
{
"name": "brainwave",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
@@ -1 +1 @@
{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.161699999999996,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":9.518499999999989,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":7.022400000000005,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":4.217900000000014,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":4.687600000000003,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.788000000000011,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":7.534500000000008,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":2.5043000000000006,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":4.5700999999999965,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":4.011300000000006,"failed":false}],[":backend/src/config.test.ts",{"duration":4.423100000000005,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":5.812899999999985,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":73.72379999999998,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.070899999999995,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":4.5079000000000065,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":3.4222000000000037,"failed":false}]]}
{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.415200000000027,"failed":false}],[":backend/dist/validators/resultSchemas.test.js",{"duration":6.726200000000006,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":7.416300000000035,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":6.132000000000005,"failed":false}],[":backend/dist/validators/authSchemas.test.js",{"duration":7.569299999999998,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":7.313600000000008,"failed":false}],[":backend/dist/validators/examSchemas.test.js",{"duration":6.513400000000047,"failed":false}],[":backend/dist/config.test.js",{"duration":3.9307000000000016,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":7.670799999999986,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":10.730400000000031,"failed":false}],[":backend/dist/validators/adminSchemas.test.js",{"duration":7.168000000000006,"failed":false}],[":backend/src/config.test.ts",{"duration":4.0274,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.865000000000009,"failed":false}],[":backend/dist/utils/tokens.test.js",{"duration":130.3977,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":4.94319999999999,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":130.18120000000005,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":5.06280000000001,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":4.779500000000013,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.795000000000016,"failed":false}],[":backend/dist/validators/setupSchemas.test.js",{"duration":5.715100000000007,"failed":false}],[":backend/dist/middleware/error.test.js",{"duration":3.313999999999993,"failed":false}],[":backend/dist/utils/asyncHandler.test.js",{"duration":5.088700000000017,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":3.638200000000012,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":3.546299999999974,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":3.1704000000000008,"failed":false}]]}
+16 -1
View File
@@ -2,5 +2,20 @@
"name": "brainwave",
"lockfileVersion": 3,
"requires": true,
"packages": {}
"packages": {
"": {
"dependencies": {
"helmet": "^8.1.0"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"name": "brainwave",
"private": true,
"version": "0.0.1"
}