Add detailed implementation plan and tests for 2026 security audit fixes
Test / backend (push) Successful in 15s
Test / frontend (push) Successful in 27s

This commit is contained in:
LaurensBartelds committed 2026-08-22 00:16:13 +02:00
1 parent 152a805961
commit ecbf842b3b
29 files changed
+828 -110

No files matched your search

+10 -2
View File
@@ -26,11 +26,19 @@ jobs:
echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$GITHUB_ENV"
echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV"
- name: Add OrangePi to known_hosts
- name: Pin server host key to known_hosts
env:
HOST_KEY: ${{ vars.SSH_HOST_PUBLIC_KEY }}
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
ssh-keyscan -H "${{ secrets.ANSIBLE_HOST }}" >> ~/.ssh/known_hosts
if [ -z "$HOST_KEY" ]; then
echo "::error::Repository variable SSH_HOST_PUBLIC_KEY is not set."
echo "::error::Pin the server's host key instead of trusting ssh-keyscan over the network:"
echo "::error::run 'ssh-keyscan -t ed25519 <host>' once from a trusted network and store the full output line in SSH_HOST_PUBLIC_KEY."
exit 1
fi
printf '%s\n' "$HOST_KEY" >> ~/.ssh/known_hosts
- name: Write Inventory
run: |
+5 -10
View File
@@ -94,6 +94,11 @@ export function runMigrations(db: Database.Database): void {
db.exec(`ALTER TABLE users ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'`);
}
// ── Add token_version if missing (JWT revocation on password change/reset) ──
if (!cols.some((c) => c.name === "token_version")) {
db.exec(`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`);
}
// ── Add is_favorite to saved_exams if missing ──
const examSavedCols = db.prepare(`PRAGMA table_info(saved_exams)`).all() as { name: string }[];
if (!examSavedCols.some((c) => c.name === 'is_favorite')) {
@@ -132,16 +137,6 @@ export function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_collections_user ON collections(user_id);
`);
// ── Promote first user to admin if no admins exist ──
const adminCount = db
.prepare(`SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'`)
.get() as { cnt: number };
if (adminCount.cnt === 0) {
db.prepare(
`UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)`,
).run();
}
// ── Seed default settings ──
const defaults: Record<string, string> = {
site_name: "BrainWave",
+127
View File
@@ -0,0 +1,127 @@
import { describe, it, expect, vi } from 'vitest'
import type { Request, Response, NextFunction } from 'express'
import * as userRepo from '../repositories/userRepo.js'
import { signToken } from '../utils/tokens.js'
import { requireAuth } from './auth.js'
function mockRes(): Response {
const res = {} as Response
res.status = vi.fn(() => res) as unknown as Response['status']
res.json = vi.fn(() => res) as unknown as Response['json']
return res
}
function makeReq(token?: string): Request {
const headers: Record<string, string> = {}
if (token) headers.authorization = `Bearer ${token}`
return { headers } as unknown as Request
}
function createUser(role: 'user' | 'admin' = 'user'): { id: number; email: string; name: string } {
const email = `user-${Math.random().toString(36).slice(2)}@example.com`
const { id } = userRepo.createUser({
email,
passwordHash: 'x',
displayName: 'Test User',
role,
emailVerified: 1,
})
return { id, email, name: 'Test User' }
}
describe('requireAuth', () => {
it('accepts a valid token and loads fresh user data from the DB', () => {
const u = createUser()
const token = signToken({ userId: u.id, email: u.email, displayName: u.name, role: 'user', tv: 0 })
const req = makeReq(token)
const res = mockRes()
const next = vi.fn() as NextFunction
requireAuth(req, res, next)
expect(next).toHaveBeenCalledOnce()
expect(res.status).not.toHaveBeenCalled()
expect(req.user?.userId).toBe(u.id)
expect(req.user?.role).toBe('user')
expect(req.user?.tv).toBe(0)
})
it('serves the current DB role, not the role claimed by an old JWT', () => {
const u = createUser()
// Token signed while the user was still an admin
const staleAdminToken = signToken({ userId: u.id, email: u.email, displayName: u.name, role: 'admin', tv: 0 })
userRepo.updateUser(u.id, { role: 'user' })
const req = makeReq(staleAdminToken)
const res = mockRes()
const next = vi.fn() as NextFunction
requireAuth(req, res, next)
expect(next).toHaveBeenCalledOnce()
expect(req.user?.role).toBe('user')
})
it('rejects tokens for deleted accounts', () => {
const u = createUser()
const token = signToken({ userId: u.id, email: u.email, displayName: u.name, role: 'user', tv: 0 })
userRepo.deleteUser(u.id)
const req = makeReq(token)
const res = mockRes()
const next = vi.fn() as NextFunction
requireAuth(req, res, next)
expect(next).not.toHaveBeenCalled()
expect(res.status).toHaveBeenCalledWith(401)
})
it('rejects tokens whose token version is stale (e.g. after password change)', () => {
const u = createUser()
const oldToken = signToken({ userId: u.id, email: u.email, displayName: u.name, role: 'user', tv: 0 })
userRepo.updateUser(u.id, { tokenVersion: 1 })
const req = makeReq(oldToken)
const res = mockRes()
const next = vi.fn() as NextFunction
requireAuth(req, res, next)
expect(next).not.toHaveBeenCalled()
expect(res.status).toHaveBeenCalledWith(401)
})
it('accepts a token signed with the current token version after rotation', () => {
const u = createUser()
userRepo.updateUser(u.id, { tokenVersion: 2 })
const freshToken = signToken({ userId: u.id, email: u.email, displayName: u.name, role: 'user', tv: 2 })
const req = makeReq(freshToken)
const res = mockRes()
const next = vi.fn() as NextFunction
requireAuth(req, res, next)
expect(next).toHaveBeenCalledOnce()
expect(req.user?.tv).toBe(2)
})
it('rejects requests without a Bearer header', () => {
const req = makeReq()
const res = mockRes()
const next = vi.fn() as NextFunction
requireAuth(req, res, next)
expect(next).not.toHaveBeenCalled()
expect(res.status).toHaveBeenCalledWith(401)
})
it('rejects garbage tokens', () => {
const req = makeReq('not.a.jwt')
const res = mockRes()
const next = vi.fn() as NextFunction
requireAuth(req, res, next)
expect(next).not.toHaveBeenCalled()
expect(res.status).toHaveBeenCalledWith(401)
})
})
+22 -1
View File
@@ -1,6 +1,7 @@
import type { Request, Response, NextFunction } from 'express'
import { verifyToken, hashToken, type JwtPayload } from '../utils/tokens.js'
import { isTokenBlacklisted } from '../utils/tokenBlacklist.js'
import { getUserById } from '../repositories/userRepo.js'
declare global {
namespace Express {
@@ -22,7 +23,7 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
}
try {
const token = header.slice(7)
req.user = verifyToken(token)
const payload = verifyToken(token)
// Check if the token has been blacklisted (e.g., after logout)
if (isTokenBlacklisted(hashToken(token))) {
@@ -30,6 +31,26 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
return
}
// Revalidate against the database: deleted accounts must not keep working,
// and role/token-version must come from the DB rather than an old JWT claim
// (revocation after demotion or password change/reset).
const user = getUserById(payload.userId)
if (!user) {
res.status(401).json({ error: 'Account no longer exists' })
return
}
if ((user.token_version ?? 0) !== payload.tv) {
res.status(401).json({ error: 'Session expired, please sign in again' })
return
}
req.user = {
userId: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
tv: payload.tv,
}
next()
} catch {
res.status(401).json({ error: 'Invalid or expired token' })
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest'
import * as userRepo from './userRepo.js'
function uniqueEmail(): string {
return `safe-${Math.random().toString(36).slice(2)}@example.com`
}
describe('userRepo.listUsers', () => {
it('never returns credential or token columns to callers', async () => {
const email = uniqueEmail()
userRepo.createUser({
email,
passwordHash: await import('bcrypt').then((b) => b.hashSync('SecretPassword1!', 10)),
displayName: 'Safe Listing',
role: 'user',
emailVerified: 0,
verifyToken: 'verify-token-hash',
resetToken: 'reset-token-hash',
})
const { users, total } = userRepo.listUsers({ search: email })
expect(total).toBe(1)
const user = users[0]!
expect(user.email).toBe(email)
expect(user.display_name).toBe('Safe Listing')
expect(user.role).toBe('user')
const keys = Object.keys(user)
expect(keys).not.toContain('password_hash')
expect(keys).not.toContain('verify_token')
expect(keys).not.toContain('verify_expires')
expect(keys).not.toContain('reset_token')
expect(keys).not.toContain('reset_expires')
})
it('still returns full rows from getUserById for internal auth use', () => {
const email = uniqueEmail()
const { id } = userRepo.createUser({
email,
passwordHash: 'internal-only',
displayName: 'Internal',
emailVerified: 1,
})
expect(userRepo.getUserById(id)?.password_hash).toBe('internal-only')
})
})
+26 -8
View File
@@ -13,6 +13,7 @@ export interface DbUser {
reset_expires: number | null
storage_quota_bytes: number
plan: string
token_version: number
created_at: number
updated_at: number
}
@@ -40,6 +41,7 @@ export interface UpdateUserData {
resetExpires?: number | null
storageQuotaBytes?: number
plan?: string
tokenVersion?: number
}
export function createUser(data: CreateUserData): { id: number } {
@@ -126,6 +128,10 @@ export function updateUser(id: number, data: UpdateUserData): boolean {
fields.push('plan = ?')
values.push(data.plan)
}
if (data.tokenVersion !== undefined) {
fields.push('token_version = ?')
values.push(data.tokenVersion)
}
if (fields.length === 0) return false
@@ -154,13 +160,30 @@ export function countAdmins(): number {
return row.cnt
}
export function listUsers(options: { limit?: number; offset?: number; search?: string } = {}): { users: DbUser[]; total: number } {
/** User row safe for API exposure — no password/token material. */
export interface SafeUser {
id: number
email: string
display_name: string
role: 'user' | 'admin'
email_verified: number
plan: string
storage_quota_bytes: number
created_at: number
}
const SAFE_USER_COLUMNS =
'id, email, display_name, role, email_verified, plan, storage_quota_bytes, created_at'
export function listUsers(options: { limit?: number; offset?: number; search?: string } = {}): { users: SafeUser[]; total: number } {
const db = getDb()
const limit = Math.min(options.limit ?? 50, 200)
const offset = options.offset ?? 0
const search = options.search ?? ''
let query = 'SELECT * FROM users'
// Explicit column list so credential/token material can never leak into
// API responses, even if new sensitive columns are added later.
let query = `SELECT ${SAFE_USER_COLUMNS} FROM users`
let countQuery = 'SELECT COUNT(*) AS total FROM users'
const params: unknown[] = []
const countParams: unknown[] = []
@@ -175,13 +198,8 @@ export function listUsers(options: { limit?: number; offset?: number; search?: s
query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'
params.push(limit, offset)
const users = db.prepare(query).all(...params) as DbUser[]
const users = db.prepare(query).all(...params) as SafeUser[]
const countRow = db.prepare(countQuery).get(...countParams) as { total: number }
return { users, total: countRow.total }
}
export function promoteFirstUserToAdmin(): void {
const db = getDb()
db.prepare("UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)").run()
}
+1 -1
View File
@@ -162,7 +162,7 @@ router.post(
},
{ provider, apiKey, model, baseURL },
)
res.json(result)
res.json(result.data)
}),
)
+17 -11
View File
@@ -5,6 +5,8 @@ import { asyncHandler } from '../utils/asyncHandler.js'
import { requireAuth } from '../middleware/auth.js'
import { generateQuestions, getHint, tutorChat } from '../services/aiAssist.js'
import { getResultById } from '../repositories/resultRepo.js'
import { enforceDailyAiQuota, recordDailyAiUsage, getUtcDateKey } from '../utils/aiQuota.js'
import { hintSchema } from '../validators/aiSchemas.js'
const router = Router()
router.use(requireAuth)
@@ -39,23 +41,24 @@ router.post(
validateBody(generateSchema),
asyncHandler(async (req, res) => {
const body = req.body as z.infer<typeof generateSchema>
const questions = await generateQuestions(body)
res.json({ questions })
const userId = req.user!.userId
enforceDailyAiQuota(userId)
const result = await generateQuestions(body)
recordDailyAiUsage(userId, getUtcDateKey(), result.usageTokens)
res.json({ questions: result.data })
}),
)
const hintSchema = z.object({
question: z.record(z.unknown()),
language: z.string().min(1).max(20),
})
router.post(
'/hint',
validateBody(hintSchema),
asyncHandler(async (req, res) => {
const body = req.body as z.infer<typeof hintSchema>
const result = await getHint(body)
res.json(result)
const body = req.body
const userId = req.user!.userId
enforceDailyAiQuota(userId)
const result = await getHint({ question: body.question, language: body.language })
recordDailyAiUsage(userId, getUtcDateKey(), result.usageTokens)
res.json(result.data)
}),
)
@@ -130,6 +133,8 @@ router.post(
state = {}
}
enforceDailyAiQuota(userId)
const reply = await tutorChat({
exam,
state,
@@ -143,8 +148,9 @@ router.post(
messages: body.messages,
language: body.language,
})
recordDailyAiUsage(userId, getUtcDateKey(), reply.usageTokens)
res.json(reply)
res.json(reply.data)
}),
)
+19 -6
View File
@@ -6,8 +6,9 @@ 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 { hashToken, verifyToken } from '../utils/tokens.js'
import { blacklistToken } from '../utils/tokenBlacklist.js'
import { AppError } from '../middleware/error.js'
import {
registerSchema,
loginSchema,
@@ -44,8 +45,7 @@ router.post(
authLimiter,
validateBody(registerSchema),
asyncHandler(async (req, res) => {
const isFirstUser = userRepo.countUsers() === 0
const result = await authService.register(req.body, isFirstUser)
const result = await authService.register(req.body)
res.json(result)
})
)
@@ -145,9 +145,18 @@ router.post(
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)
// Blacklist until the token's real expiry so it cannot be replayed even
// after the in-memory blacklist is reloaded from SQLite on restart
try {
const decoded = verifyToken(token)
const expiresAt =
typeof decoded.exp === 'number' && decoded.exp * 1000 > Date.now()
? decoded.exp * 1000
: Date.now() + 60 * 1000
blacklistToken(tokenHash, expiresAt)
} catch {
// Token already failed verification — nothing worth blacklisting
}
}
res.json({ success: true })
})
@@ -158,6 +167,10 @@ router.delete(
'/me',
requireAuth,
asyncHandler(async (req: Request, res) => {
const user = userRepo.getUserById(req.user!.userId)
if (user?.role === 'admin' && userRepo.countAdmins() <= 1) {
throw new AppError(400, 'Cannot delete the last admin account')
}
authService.deleteUserAccount(req.user!.userId)
res.json({ success: true })
})
+7 -10
View File
@@ -1,16 +1,10 @@
import { Router } from 'express'
import { z } from 'zod'
import { validateBody } from '../middleware/validate.js'
import { asyncHandler } from '../utils/asyncHandler.js'
import { requireAuth } from '../middleware/auth.js'
import { gradeQuestionWithAI } from '../services/aiGrading.js'
const gradeRequestSchema = z.object({
type: z.enum(['essay', 'short-answer', 'translation', 'math', 'ordering', 'match']),
question: z.record(z.unknown()),
answer: z.unknown(),
language: z.string().min(1),
})
import { enforceDailyAiQuota, recordDailyAiUsage, getUtcDateKey } from '../utils/aiQuota.js'
import { gradeRequestSchema, type GradeRequestBody } from '../validators/gradeSchemas.js'
const router = Router()
@@ -20,9 +14,12 @@ router.post(
'/',
validateBody(gradeRequestSchema),
asyncHandler(async (req, res) => {
const { type, question, answer, language } = req.body as z.infer<typeof gradeRequestSchema>
const { type, question, answer, language } = req.body as GradeRequestBody
const userId = req.user!.userId
enforceDailyAiQuota(userId)
const result = await gradeQuestionWithAI({ type, question, answer, language })
res.json(result)
recordDailyAiUsage(userId, getUtcDateKey(), result.usageTokens)
res.json(result.data)
}),
)
+6 -2
View File
@@ -7,6 +7,7 @@ import * as resultRepo from '../repositories/resultRepo.js'
import * as examRepo from '../repositories/examRepo.js'
import { submitResultSchema, resultIdParamSchema } from '../validators/resultSchemas.js'
import { analyzeResults } from '../services/studyCoach.js'
import { enforceDailyAiQuota, recordDailyAiUsage, getUtcDateKey } from '../utils/aiQuota.js'
import type { Request } from 'express'
const router = Router()
@@ -84,9 +85,12 @@ router.delete(
router.get(
'/analyze',
asyncHandler(async (req: Request, res) => {
const rawResults = resultRepo.getResultsWithExamJson(req.user!.userId, 200)
const userId = req.user!.userId
const rawResults = resultRepo.getResultsWithExamJson(userId, 200)
const lang = typeof req.query.lang === 'string' ? req.query.lang : 'en'
enforceDailyAiQuota(userId)
const analysis = await analyzeResults(rawResults, lang)
recordDailyAiUsage(userId, getUtcDateKey(), analysis.usageTokens)
// Find weakest exam (lowest score that still exists in workspace)
let weakestExamId: number | null = null
@@ -107,7 +111,7 @@ router.get(
}
}
res.json({ ...analysis, weakestExamId })
res.json({ ...analysis.data, weakestExamId })
})
)
+8 -8
View File
@@ -22,8 +22,11 @@ router.get(
'/status',
asyncHandler(async (_req, res) => {
const title = getSiteTitle()
// Setup is complete once an admin account exists — not merely because
// default settings were seeded
const completed = userRepo.countAdmins() > 0
res.json({
completed: !!title,
completed,
siteTitle: title ?? undefined,
})
})
@@ -38,16 +41,12 @@ router.post(
// Atomic transaction prevents race conditions between concurrent setup requests
const tx = db.transaction(() => {
const title = settingsRepo.getSetting('site_name')
if (title) {
// Gate on admins, not on users or settings: a fresh instance has default
// settings seeded, and an instance that lost its admin can re-bootstrap
if (userRepo.countAdmins() > 0) {
throw new AppError(400, 'Setup already completed')
}
const userCount = userRepo.countUsers()
if (userCount > 0) {
throw new AppError(400, 'Users already exist')
}
const {
email,
password,
@@ -99,6 +98,7 @@ router.post(
email,
displayName,
role: 'admin',
tv: 0,
})
res.status(201).json({
+14
View File
@@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest'
import { canClone } from './share.js'
describe('canClone', () => {
it('rejects view-only shares', () => {
expect(canClone('view')).toBe(false)
})
it('allows clone-mode shares and legacy rows without a mode', () => {
expect(canClone('clone')).toBe(true)
expect(canClone(null)).toBe(true)
expect(canClone(undefined)).toBe(true)
})
})
+9
View File
@@ -7,6 +7,12 @@ import type { Request } from 'express'
const router = Router()
/** Server-side enforcement of the share mode: 'view' shares must not be
* clonable (the frontend hides the button, but that is not a control). */
export function canClone(shareMode: string | null | undefined): boolean {
return shareMode !== 'view'
}
// GET /api/share/:token — public, no auth required
router.get(
'/:token',
@@ -37,6 +43,9 @@ router.post(
if (!source) {
throw new AppError(404, 'Shared exam not found')
}
if (!canClone(source.share_mode)) {
throw new AppError(403, 'This exam is shared as view-only and cannot be saved')
}
const { id } = examRepo.cloneExam(req.user!.userId, source, token)
res.status(201).json({ id, title: source.title + ' (copy)' })
})
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect, vi } from 'vitest'
import bcrypt from 'bcrypt'
vi.mock('../utils/email.js', async (importOriginal) => ({
...(await importOriginal<object>()),
sendVerificationEmail: vi.fn(async () => {}),
sendPasswordResetEmail: vi.fn(async () => {}),
}))
import * as authService from './authService.js'
import { sendPasswordResetEmail } from '../utils/email.js'
import * as userRepo from '../repositories/userRepo.js'
async function seedUser(overrides: Partial<userRepo.CreateUserData> = {}): Promise<number> {
const email = overrides.email ?? `svc-${Math.random().toString(36).slice(2)}@example.com`
const { id } = userRepo.createUser({
email,
passwordHash: await bcrypt.hash('OldPassword1!', 10),
displayName: 'Svc Test',
emailVerified: 1,
...overrides,
})
return id
}
describe('authService.register', () => {
it('registers new accounts as plain users (admin bootstrap is the setup wizard)', async () => {
const email = `new-${Math.random().toString(36).slice(2)}@example.com`
const result = await authService.register({
email,
password: 'Sup3rSecure!pass',
displayName: 'New User',
})
if (!('token' in result)) throw new Error('expected an AuthResult with a token')
expect(result.token).toBeTruthy()
expect(result.user.role).toBe('user')
const dbUser = userRepo.getUserByEmail(email)
expect(dbUser?.role).toBe('user')
expect(dbUser?.email_verified).toBe(0)
})
it('responds to duplicate emails with a token-less success-shaped body (no enumeration oracle)', async () => {
const id = await seedUser()
const existing = userRepo.getUserById(id)!
const before = userRepo.countUsers()
const result = await authService.register({
email: existing.email.toUpperCase(),
password: 'Whatever!123',
displayName: 'Imposter',
})
expect('token' in result).toBe(false)
expect('message' in result && typeof result.message).toBe('string')
expect(userRepo.countUsers()).toBe(before)
// The original account must be untouched
const after = userRepo.getUserById(id)!
expect(after.display_name).toBe(existing.display_name)
})
})
describe('password rotation revokes sessions', () => {
it('changePassword bumps token_version', async () => {
const id = await seedUser()
const before = userRepo.getUserById(id)!
await authService.changePassword(id, { currentPassword: 'OldPassword1!', newPassword: 'N3wPassword!x' })
const after = userRepo.getUserById(id)!
expect(after.password_hash).not.toBe(before.password_hash)
expect(after.token_version).toBe((before.token_version ?? 0) + 1)
})
it('changePassword rejects a wrong current password', async () => {
const id = await seedUser()
const before = userRepo.getUserById(id)!
await expect(
authService.changePassword(id, { currentPassword: 'WrongPassword!', newPassword: 'N3wPassword!x' }),
).rejects.toThrow()
expect(userRepo.getUserById(id)!.token_version).toBe(before.token_version)
})
it('resetPassword bumps token_version and clears the reset token', async () => {
const id = await seedUser({ emailVerified: 0 })
const user = userRepo.getUserById(id)!
const email = user.email
await authService.forgotPassword(email)
const calls = vi.mocked(sendPasswordResetEmail).mock.calls
const rawToken = calls.at(-1)?.[1] as string | undefined
expect(rawToken).toBeTruthy()
await authService.resetPassword({ token: rawToken!, newPassword: 'BrandNew!123' })
const after = userRepo.getUserById(id)!
expect(after.token_version).toBe((user.token_version ?? 0) + 1)
expect(after.reset_token).toBeNull()
expect(after.reset_expires).toBeNull()
expect(bcrypt.compareSync('BrandNew!123', after.password_hash)).toBe(true)
})
})
+42 -35
View File
@@ -15,14 +15,41 @@ export interface AuthResult {
user: { id: number; email: string; displayName: string; role: string }
}
export async function register(input: RegisterInput, isFirstUser: boolean): Promise<AuthResult> {
const existing = userRepo.getUserByEmail(input.email)
if (existing) {
throw new AppError(400, 'Email already registered')
/** Response for a registration attempt we did not act on (email already exists).
* Deliberately indistinguishable from success so the endpoint cannot be used
* to enumerate registered email addresses. */
export interface RegistrationPending {
message: string
}
function toAuthResult(user: userRepo.DbUser): AuthResult {
return {
token: signToken({
userId: user.id,
email: user.email,
displayName: user.display_name,
role: user.role as 'user' | 'admin',
tv: user.token_version ?? 0,
}),
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
},
}
}
export async function register(input: RegisterInput): Promise<AuthResult | RegistrationPending> {
// Hash before the duplicate check so response timing does not reveal whether
// the email is already registered (user enumeration)
const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS)
const role = isFirstUser ? 'admin' : 'user'
const existing = userRepo.getUserByEmail(input.email)
if (existing) {
return { message: 'Check your inbox to finish setting up your account.' }
}
const rawVerifyToken = generateVerificationToken()
const verifyTokenHash = hashToken(rawVerifyToken)
const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS
@@ -31,7 +58,7 @@ export async function register(input: RegisterInput, isFirstUser: boolean): Prom
email: input.email,
passwordHash,
displayName: input.displayName,
role,
role: 'user',
emailVerified: 0,
verifyToken: verifyTokenHash,
verifyExpires,
@@ -42,20 +69,7 @@ export async function register(input: RegisterInput, isFirstUser: boolean): Prom
// Don't await — fire and forget so registration isn't blocked by email failures
sendVerificationEmail(user.email, rawVerifyToken).catch(() => {})
return {
token: signToken({
userId: user.id,
email: user.email,
displayName: user.display_name,
role: user.role as 'user' | 'admin',
}),
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
},
}
return toAuthResult(user)
}
export async function login(input: LoginInput): Promise<AuthResult> {
@@ -73,20 +87,7 @@ export async function login(input: LoginInput): Promise<AuthResult> {
throw new AppError(403, 'Please verify your email before logging in')
}
return {
token: signToken({
userId: user.id,
email: user.email,
displayName: user.display_name,
role: user.role as 'user' | 'admin',
}),
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
},
}
return toAuthResult(user)
}
export function getMe(userId: number) {
@@ -128,7 +129,11 @@ export async function changePassword(userId: number, input: ChangePasswordInput)
}
const passwordHash = await bcrypt.hash(input.newPassword, SALT_ROUNDS)
userRepo.updateUser(userId, { passwordHash })
// Bump token version so tokens issued before the change stop working
userRepo.updateUser(userId, {
passwordHash,
tokenVersion: (user.token_version ?? 0) + 1,
})
}
export async function verifyEmail(input: VerifyEmailInput) {
@@ -192,10 +197,12 @@ export async function resetPassword(input: ResetPasswordInput) {
}
const passwordHash = await bcrypt.hash(input.newPassword, SALT_ROUNDS)
// Bump token version so tokens from before the reset stop working
userRepo.updateUser(user.id, {
passwordHash,
resetToken: null,
resetExpires: null,
tokenVersion: (user.token_version ?? 0) + 1,
})
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest'
import * as settingsRepo from '../repositories/settingsRepo.js'
import * as aiUsageRepo from '../repositories/aiUsageRepo.js'
import * as userRepo from '../repositories/userRepo.js'
import { enforceDailyAiQuota, getDailyTokenLimit, getUtcDateKey } from './aiQuota.js'
import { AppError } from '../middleware/error.js'
function seedUser(): number {
const email = `quota-${Math.random().toString(36).slice(2)}@example.com`
return userRepo.createUser({
email,
passwordHash: 'x',
displayName: 'Quota Test',
emailVerified: 1,
}).id
}
describe('daily AI token quota', () => {
it('allows everything when no limit is configured', () => {
settingsRepo.setSetting('ai_daily_token_limit', '0')
expect(getDailyTokenLimit()).toBeNull()
expect(() => enforceDailyAiQuota(seedUser())).not.toThrow()
})
it('blocks with a 429 once used tokens reach the configured limit', () => {
settingsRepo.setSetting('ai_daily_token_limit', '1000')
const userId = seedUser()
aiUsageRepo.incrementDailyUsage(userId, getUtcDateKey(), 1000, 1)
expect(() => enforceDailyAiQuota(userId)).toThrowError(AppError)
try {
enforceDailyAiQuota(userId)
} catch (err) {
expect(err).toBeInstanceOf(AppError)
expect((err as AppError).statusCode).toBe(429)
}
})
it('allows users under the limit and isolates users from each other', () => {
settingsRepo.setSetting('ai_daily_token_limit', '1000')
const heavy = seedUser()
const fresh = seedUser()
aiUsageRepo.incrementDailyUsage(heavy, getUtcDateKey(), 999, 1)
expect(() => enforceDailyAiQuota(heavy)).not.toThrow()
expect(() => enforceDailyAiQuota(fresh)).not.toThrow()
})
it('only counts today’s usage', () => {
settingsRepo.setSetting('ai_daily_token_limit', '1000')
const userId = seedUser()
aiUsageRepo.incrementDailyUsage(userId, '2000-01-01', 999_999, 1)
expect(() => enforceDailyAiQuota(userId)).not.toThrow()
})
})
+11
View File
@@ -1,5 +1,6 @@
import { getSetting } from '../repositories/settingsRepo.js'
import { getDailyUsage, incrementDailyUsage } from '../repositories/aiUsageRepo.js'
import { AppError } from '../middleware/error.js'
export interface AiQuotaStatus {
allowed: boolean
@@ -38,3 +39,13 @@ export function checkDailyAiQuota(userId: number): AiQuotaStatus {
export function recordDailyAiUsage(userId: number, periodDate: string, tokens: number): void {
incrementDailyUsage(userId, periodDate, tokens, 1)
}
/**
* Throws a 429 when the user has exhausted the admin-configured daily token
* limit. Call before every AI provider invocation; pair with recordDailyAiUsage.
*/
export function enforceDailyAiQuota(userId: number): void {
if (!checkDailyAiQuota(userId).allowed) {
throw new AppError(429, 'Daily AI usage limit reached. Please try again tomorrow.')
}
}
+2 -1
View File
@@ -18,7 +18,8 @@ function initCache(): void {
);
CREATE INDEX IF NOT EXISTS idx_token_blacklist_expires ON token_blacklist(expires_at);
`)
// Load recent blacklisted tokens into memory
// Purge rows that can never matter again, then load the rest into memory
db.prepare('DELETE FROM token_blacklist WHERE expires_at <= ?').run(Date.now())
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) {
+3 -2
View File
@@ -4,13 +4,14 @@ describe('tokens', () => {
it('round-trips JWT payload', async () => {
process.env.JWT_SECRET = 'test-secret-for-unit-tests-only'
const { signToken, verifyToken } = await import('./tokens')
const payload = { userId: 1, email: 'a@b.com', displayName: 'Test', role: 'user' as const }
const payload = { userId: 1, email: 'a@b.com', displayName: 'Test', role: 'user' as const, tv: 3 }
const token = signToken(payload)
const decoded = verifyToken(token)
expect(decoded.userId).toBe(1)
expect(decoded.email).toBe('a@b.com')
expect(decoded.displayName).toBe('Test')
expect(decoded.role).toBe('user')
expect(decoded.tv).toBe(3)
})
it('throws on invalid token', async () => {
@@ -22,7 +23,7 @@ describe('tokens', () => {
it('throws on tampered token', async () => {
process.env.JWT_SECRET = 'test-secret-for-unit-tests-only'
const { signToken, verifyToken } = await import('./tokens')
const payload = { userId: 1, email: 'a@b.com', displayName: 'Test', role: 'user' as const }
const payload = { userId: 1, email: 'a@b.com', displayName: 'Test', role: 'user' as const, tv: 0 }
const token = signToken(payload)
const tampered = token.slice(0, -5) + 'xxxxx'
expect(() => verifyToken(tampered)).toThrow()
+7 -2
View File
@@ -7,14 +7,19 @@ export interface JwtPayload {
email: string
displayName: string
role: 'user' | 'admin'
/** Token version — must match the user's current token_version or the token is rejected */
tv: number
}
export function signToken(payload: JwtPayload): string {
return jwt.sign(payload, config.jwtSecret, { expiresIn: config.jwtExpiresIn } as jwt.SignOptions)
}
export function verifyToken(token: string): JwtPayload {
return jwt.verify(token, config.jwtSecret) as JwtPayload
/** Verified JWT body: our claims plus the registered exp/iat timestamps. */
export type VerifiedToken = JwtPayload & { exp?: number; iat?: number }
export function verifyToken(token: string): VerifiedToken {
return jwt.verify(token, config.jwtSecret) as VerifiedToken
}
export function generateVerificationToken(): string {
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest'
import { hintSchema, MAX_HINT_QUESTION_CHARS } from './aiSchemas.js'
describe('hintSchema', () => {
it('accepts a normal hint request', () => {
const result = hintSchema.safeParse({
question: { type: 'short-answer', prompt: 'What is 2+2?' },
language: 'en',
})
expect(result.success).toBe(true)
})
it(`rejects questions whose JSON exceeds ${MAX_HINT_QUESTION_CHARS} chars`, () => {
const result = hintSchema.safeParse({
question: { prompt: 'x'.repeat(MAX_HINT_QUESTION_CHARS + 1) },
language: 'en',
})
expect(result.success).toBe(false)
if (!result.success) {
expect(JSON.stringify(result.error.issues)).toContain('too large')
}
})
})
+25
View File
@@ -0,0 +1,25 @@
import { z } from 'zod'
export const MAX_HINT_QUESTION_CHARS = 8000
function serializedSize(value: unknown): number {
if (value === undefined || value === null) return 0
return JSON.stringify(value).length
}
export const hintSchema = z
.object({
question: z.record(z.unknown()),
language: z.string().min(1).max(20),
})
.superRefine((val, ctx) => {
if (serializedSize(val.question) > MAX_HINT_QUESTION_CHARS) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Question is too large for AI hints',
path: ['question'],
})
}
})
export type HintRequestBody = z.infer<typeof hintSchema>
@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest'
import {
gradeRequestSchema,
MAX_GRADE_QUESTION_CHARS,
MAX_GRADE_ANSWER_CHARS,
} from './gradeSchemas.js'
const validBase = {
question: { prompt: 'Explain photosynthesis' },
answer: 'Plants convert light into chemical energy',
language: 'en',
}
describe('gradeRequestSchema', () => {
it('accepts a well-formed grading request', () => {
const result = gradeRequestSchema.safeParse({ type: 'essay', ...validBase })
expect(result.success).toBe(true)
})
it(`rejects questions whose JSON exceeds ${MAX_GRADE_QUESTION_CHARS} chars`, () => {
const result = gradeRequestSchema.safeParse({
type: 'essay',
question: { prompt: 'x'.repeat(MAX_GRADE_QUESTION_CHARS + 1) },
answer: 'irrelevant',
language: 'en',
})
expect(result.success).toBe(false)
if (!result.success) {
expect(JSON.stringify(result.error.issues)).toContain('too large')
}
})
it(`rejects answers whose JSON exceeds ${MAX_GRADE_ANSWER_CHARS} chars`, () => {
const result = gradeRequestSchema.safeParse({
type: 'essay',
question: { prompt: 'small' },
answer: 'y'.repeat(MAX_GRADE_ANSWER_CHARS + 1),
language: 'en',
})
expect(result.success).toBe(false)
if (!result.success) {
expect(JSON.stringify(result.error.issues)).toContain('too large')
}
})
it('rejects unknown question types', () => {
const result = gradeRequestSchema.safeParse({
type: 'flashcard',
question: { prompt: 'hi' },
answer: 'yo',
language: 'en',
})
expect(result.success).toBe(false)
})
it('bounds the language field', () => {
const result = gradeRequestSchema.safeParse({
type: 'essay',
question: { prompt: 'hi' },
answer: 'yo',
language: 'l'.repeat(500),
})
expect(result.success).toBe(false)
})
})
+37
View File
@@ -0,0 +1,37 @@
import { z } from 'zod'
/** Serialized size caps: the grading prompt is built from client-supplied
* question/answer objects, so these bound what can be billed per request. */
export const MAX_GRADE_QUESTION_CHARS = 8000
export const MAX_GRADE_ANSWER_CHARS = 4000
function serializedSize(value: unknown): number {
if (value === undefined || value === null) return 0
return JSON.stringify(value).length
}
const gradeRequestBase = z.object({
type: z.enum(['essay', 'short-answer', 'translation', 'math', 'ordering', 'match']),
question: z.record(z.unknown()),
answer: z.unknown(),
language: z.string().min(1).max(30),
})
export const gradeRequestSchema = gradeRequestBase.superRefine((val, ctx) => {
if (serializedSize(val.question) > MAX_GRADE_QUESTION_CHARS) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Question is too large for AI grading',
path: ['question'],
})
}
if (serializedSize(val.answer) > MAX_GRADE_ANSWER_CHARS) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Answer is too large for AI grading',
path: ['answer'],
})
}
})
export type GradeRequestBody = z.infer<typeof gradeRequestSchema>
+20 -2
View File
@@ -68,7 +68,7 @@ describe('submitResultSchema', () => {
expect(result.success).toBe(true)
})
it('accepts negative score_points because schema allows any number', () => {
it('rejects negative score_points (client-supplied scores are bounded)', () => {
const result = submitResultSchema.safeParse({
title: 'My Exam',
score_points: -1,
@@ -86,7 +86,25 @@ describe('submitResultSchema', () => {
},
exam: { title: 'My Exam', questions: [] },
})
expect(result.success).toBe(true)
expect(result.success).toBe(false)
})
it('rejects non-integer counts and absurd magnitudes', () => {
const base = {
title: 'My Exam',
score_points: 8,
total_points: 10,
correct_count: 8,
graded_count: 10,
elapsed_ms: 120000,
state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: Date.now(), feedbackMode: 'end' },
exam: { title: 'My Exam', questions: [] },
}
expect(submitResultSchema.safeParse({ ...base, correct_count: 1.5 }).success).toBe(false)
expect(submitResultSchema.safeParse({ ...base, score_points: 1e12 }).success).toBe(false)
expect(submitResultSchema.safeParse({ ...base, elapsed_ms: -5 }).success).toBe(false)
expect(submitResultSchema.safeParse({ ...base, exam_id: -3 }).success).toBe(false)
expect(submitResultSchema.safeParse({ ...base, title: 't'.repeat(301) }).success).toBe(false)
})
it('accepts immediate feedback mode', () => {
+10 -8
View File
@@ -1,14 +1,16 @@
import { z } from 'zod'
// Scores/counts arrive from the client, so they are bounded to keep forged
// garbage out of analytics while accepting every value honest clients send.
export const submitResultSchema = z.object({
exam_id: z.number().nullable().optional(),
title: z.string().min(1),
score_points: z.number(),
total_points: z.number(),
correct_count: z.number(),
graded_count: z.number(),
passing_score: z.number().optional(),
elapsed_ms: z.number(),
exam_id: z.number().int().positive().nullable().optional(),
title: z.string().min(1).max(300),
score_points: z.number().finite().min(0).max(1e9),
total_points: z.number().finite().min(0).max(1e9),
correct_count: z.number().int().min(0).max(1e6),
graded_count: z.number().int().min(0).max(1e6),
passing_score: z.number().finite().min(0).max(1e9).optional(),
elapsed_ms: z.number().int().min(0).max(2_147_483_647),
state: z.record(z.unknown()),
exam: z.record(z.unknown()),
})
@@ -0,0 +1,104 @@
# Security Audit Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix all vulnerabilities confirmed by the 2026-08-21 full-codebase security audit (5 confirmed ≥8/10 confidence + 6 sub-threshold verified findings).
**Architecture:** Three clusters. (A) Session security: JWTs gain a `tv` (token version) claim; `requireAuth` revalidates the user against SQLite on every request (deleted accounts, demoted roles, password-rotation revocation); logout blacklists until real `exp`. (B) AI cost control: wire the existing-but-dead quota helpers into every provider call path and cap attacker-controlled prompt sizes. (C) Bootstrap & hygiene: make the setup wizard the only admin bootstrap, stop register-time auto-admin, sanitize admin user listings, bound result schemas, enforce view-only share mode server-side, pin SSH host keys in CI.
**Tech Stack:** Express 5, better-sqlite3 (sync), jsonwebtoken 9, zod 3, vitest 4, Gitea Actions.
**Spec:** Security audit report delivered in this session (2026-08-21). Findings referenced below as Vuln N / sub-threshold N.
## Global Constraints
- Do not alter lines containing redacted numeric literals in `userRepo.ts` (`storageQuotaBytes ?? …`) and `migrations.ts` (`storage_quota_bytes … DEFAULT`) — edit around them.
- Keep HTTP response shapes of AI endpoints unchanged (`{ data, usageTokens }` payloads stay) so the frontend needs no changes.
- Tests run with `cd backend && npx vitest run`; vitest.setup stubs `JWT_SECRET`, `DB_PATH=:memory:`; `getDb()` runs full migrations on first use.
- No git commits — user has not requested them; leave changes in working tree.
- Existing deployments upgrade transparently; all users are logged out once by the new `tv` claim (accepted, noted in final report).
---
### Task 1: Token versioning + DB revalidation (Vuln 3, Vuln 4, sub-threshold 1)
**Files:**
- Modify: `backend/src/db/migrations.ts` (add column)
- Modify: `backend/src/utils/tokens.ts` (JwtPayload.tv)
- Modify: `backend/src/repositories/userRepo.ts` (token_version plumbing)
- Modify: `backend/src/middleware/auth.ts` (revalidation)
- Modify: `backend/src/services/authService.ts` (sign with tv, bump on password events)
- Modify: `backend/src/routes/setup.ts` (sign with tv)
- Modify: `backend/src/routes/auth.ts` (logout uses decoded exp)
- Modify: `backend/src/utils/tokenBlacklist.ts` (purge expired rows at init)
- Test: `backend/src/utils/tokens.test.ts` (update payload fixtures), `backend/src/middleware/auth.test.ts` (new)
**Interfaces:**
- Produces: `JwtPayload { userId, email, displayName, role, tv }`; `DbUser.token_version: number`; `UpdateUserData.tokenVersion?: number`.
- [ ] **Step 1: Write failing tests** (`auth.test.ts`): valid token passes and `req.user.role` comes from DB; deleted user → 401; tv mismatch → 401. Update `tokens.test.ts` payload fixtures to include `tv: 0` (type error until implemented = failing).
- [ ] **Step 2: Run** `npx vitest run src/middleware/auth.test.ts src/utils/tokens.test.ts` → FAIL (missing tv / column).
- [ ] **Step 3: Implement** per code in execution notes: migration guard block; `token_version INTEGER NOT NULL DEFAULT 0`; JwtPayload gains required `tv`; requireAuth loads user via `getUserById`, 401 on missing user, 401 on `user.token_version !== payload.tv`, rebuilds `req.user` from the DB row; changePassword/resetPassword bump tokenVersion; logout blacklists `(decoded.exp ?? now+86400)*1000`; initCache purges expired rows before loading.
- [ ] **Step 4: Run** → PASS.
- [ ] **Step 5: Full suite** `npx vitest run` → PASS.
### Task 2: Setup-gated bootstrap, no register auto-admin, enumeration-safe register, last-admin guard (sub-threshold 2, 3, 4)
**Files:**
- Modify: `backend/src/routes/setup.ts` (status + POST gate on countAdmins)
- Modify: `backend/src/routes/auth.ts` (drop isFirstUser; last-admin self-delete guard)
- Modify: `backend/src/services/authService.ts` (register signature, role always 'user', duplicate → generic success after timing-equalized hash)
- Modify: `backend/src/db/migrations.ts` (remove auto-promote block)
- Modify: `backend/src/repositories/userRepo.ts` (remove dead promoteFirstUserToAdmin)
- Test: `backend/src/services/authService.test.ts` (new)
- [ ] **Step 1: Failing tests**: duplicate-email register resolves `{ message }` with no token; fresh register returns token with role 'user'; both take ~equal time (hash-before-check asserted structurally, not by timing).
- [ ] **Step 2: Run** → FAIL.
- [ ] **Step 3: Implement**: setup status/gate switch to `countAdmins() > 0`; register drops isFirstUser param, hashes password before duplicate check, returns `{ message: 'Check your inbox…' }` on duplicates; `DELETE /api/auth/me` refuses when `role==='admin' && countAdmins()<=1`; migrations/userRepo promotion code deleted.
- [ ] **Step 4: Run** → PASS. **Step 5: Full suite** → PASS.
### Task 3: AI quota enforcement + prompt size caps (Vuln 1, Vuln 2)
**Files:**
- Modify: `backend/src/utils/aiQuota.ts` (add enforceDailyAiQuota throwing AppError 429)
- Create: `backend/src/validators/gradeSchemas.ts` (schema with serialized-size superRefine caps), `backend/src/validators/aiSchemas.ts` (hintSchema moved + cap)
- Modify: `backend/src/routes/ai.ts`, `backend/src/routes/grade.ts`, `backend/src/routes/results.ts` (enforce before provider call; recordDailyAiUsage after, using returned usageTokens)
- Test: `backend/src/utils/aiQuota.test.ts`, `backend/src/validators/gradeSchemas.test.ts`
**Interfaces:**
- Consumes: `recordDailyAiUsage(userId, getUtcDateKey(), tokens)`; services' existing `AiUsageResult.usageTokens`.
- Produces: `enforceDailyAiQuota(userId): void` (throws `AppError(429)`).
- [ ] **Step 1: Failing tests**: quota throws 429 when used ≥ limit; passes under limit; passes when limit unset ("0"/absent → null → unlimited). Grade schema rejects question JSON > 8000 chars / answer > 4000 chars.
- [ ] **Step 2: Run** → FAIL. **Step 3: Implement. Step 4: Run → PASS. Step 5: Full suite → PASS.**
### Task 4: Admin user DTO + result schema bounds (sub-threshold 5, 6)
**Files:**
- Modify: `backend/src/repositories/userRepo.ts` (`SafeUser`, explicit SELECT columns in listUsers)
- Modify: `backend/src/validators/resultSchemas.ts` (finite/int/min/max bounds, title ≤300)
- Test: `backend/src/validators/resultSchemas.test.ts`, repo test for sanitized listing
- [ ] **Step 1: Failing tests**: listUsers never includes password_hash/verify_token/reset_token/reset_expires keys; schema rejects negative counts, non-integers, huge floats, oversized titles.
- [ ] **Step 2–5:** implement → PASS → full suite PASS.
### Task 5: View-only share clone block (Vuln 5)
**Files:**
- Modify: `backend/src/routes/share.ts`
- Test: `backend/src/routes/share.test.ts`
- [ ] Export pure `canClone(shareMode?: string | null): boolean` ('view' → false; 'clone'/null/undefined → true). Failing test first, then guard `if (!canClone(source.share_mode)) throw new AppError(403, 'This exam is shared as view-only and cannot be saved')` before `cloneExam`. PASS + full suite.
### Task 6: CI SSH host-key pinning (sub-threshold 7)
**Files:** Modify `.gitea/workflows/deploy.yml`
- [ ] Replace keyscan step: write `${{ vars.SSH_HOST_PUBLIC_KEY }}` via env-indirection into known_hosts; hard-fail with setup instructions when unset. Validate YAML parses.
### Task 7: Verification
- [ ] `cd backend && npx tsc --noEmit && npx vitest run` — all green.
- [ ] `cd frontend && npm run build` (or tsc -b) — typecheck green (no frontend changes expected).
- [ ] Ultracode adversarial review workflow over the full diff; fix anything CONFIRMED.
- [ ] Final report mapping each finding → fix → evidence.
@@ -1 +1 @@
{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":0,"failed":true}],[":backend/dist/validators/resultSchemas.test.js",{"duration":0,"failed":true}],[":backend/src/validators/authSchemas.test.ts",{"duration":0,"failed":true}],[":backend/src/validators/resultSchemas.test.ts",{"duration":0,"failed":true}],[":backend/dist/validators/authSchemas.test.js",{"duration":0,"failed":true}],[":frontend/src/utils/storage.test.ts",{"duration":0,"failed":true}],[":backend/dist/validators/examSchemas.test.js",{"duration":0,"failed":true}],[":backend/dist/config.test.js",{"duration":0,"failed":true}],[":backend/src/validators/adminSchemas.test.ts",{"duration":0,"failed":true}],[":frontend/src/utils/latex.test.ts",{"duration":0,"failed":true}],[":backend/dist/validators/adminSchemas.test.js",{"duration":0,"failed":true}],[":backend/src/config.test.ts",{"duration":0,"failed":true}],[":backend/src/validators/examSchemas.test.ts",{"duration":0,"failed":true}],[":backend/dist/utils/tokens.test.js",{"duration":0,"failed":true}],[":frontend/src/utils/validation.test.ts",{"duration":0,"failed":true}],[":backend/src/utils/tokens.test.ts",{"duration":0,"failed":true}],[":frontend/src/utils/slug.test.ts",{"duration":0,"failed":true}],[":backend/src/utils/asyncHandler.test.ts",{"duration":0,"failed":true}],[":backend/src/validators/setupSchemas.test.ts",{"duration":0,"failed":true}],[":backend/dist/validators/setupSchemas.test.js",{"duration":0,"failed":true}],[":backend/dist/middleware/error.test.js",{"duration":0,"failed":true}],[":backend/dist/utils/asyncHandler.test.js",{"duration":0,"failed":true}],[":frontend/src/utils/formatting.test.ts",{"duration":0,"failed":true}],[":backend/src/middleware/error.test.ts",{"duration":0,"failed":true}],[":frontend/src/utils/api.test.ts",{"duration":0,"failed":true}],[":backend/src/repositories/storageRepo.test.ts",{"duration":0,"failed":true}],[":backend/dist/repositories/storageRepo.test.js",{"duration":0,"failed":true}]]}
{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":9.265338999999983,"failed":false}],[":backend/dist/validators/resultSchemas.test.js",{"duration":0,"failed":true}],[":backend/src/validators/authSchemas.test.ts",{"duration":22.575676000000044,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":9.748771000000033,"failed":false}],[":backend/dist/validators/authSchemas.test.js",{"duration":0,"failed":true}],[":frontend/src/utils/storage.test.ts",{"duration":6.310062000000016,"failed":false}],[":backend/dist/validators/examSchemas.test.js",{"duration":0,"failed":true}],[":backend/dist/config.test.js",{"duration":0,"failed":true}],[":backend/src/validators/adminSchemas.test.ts",{"duration":33.639384000000064,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":0,"failed":true}],[":backend/dist/validators/adminSchemas.test.js",{"duration":0,"failed":true}],[":backend/src/config.test.ts",{"duration":14.69092599999999,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":33.61004700000001,"failed":false}],[":backend/dist/utils/tokens.test.js",{"duration":0,"failed":true}],[":frontend/src/utils/validation.test.ts",{"duration":11.680594999999983,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":44.34940499999999,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":5.085611999999969,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":5.737424999999973,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":28.528040000000033,"failed":false}],[":backend/dist/validators/setupSchemas.test.js",{"duration":0,"failed":true}],[":backend/dist/middleware/error.test.js",{"duration":0,"failed":true}],[":backend/dist/utils/asyncHandler.test.js",{"duration":0,"failed":true}],[":frontend/src/utils/formatting.test.ts",{"duration":4.583577999999989,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":8.906761999999958,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":9.23972999999998,"failed":false}],[":backend/src/repositories/storageRepo.test.ts",{"duration":23.65549999999996,"failed":false}],[":backend/dist/repositories/storageRepo.test.js",{"duration":0,"failed":true}],[":backend/src/validators/gradeSchemas.test.ts",{"duration":16.57176800000002,"failed":false}],[":backend/src/utils/aiQuota.test.ts",{"duration":0,"failed":true}],[":backend/src/validators/collectionSchemas.test.ts",{"duration":15.043968000000007,"failed":false}],[":backend/src/validators/aiSchemas.test.ts",{"duration":14.30532999999997,"failed":false}],[":backend/src/services/authService.test.ts",{"duration":0,"failed":true}],[":backend/src/middleware/auth.test.ts",{"duration":0,"failed":true}]]}