{ "audit": "Study Desk comprehensive read-only audit", "generatedAt": "2026-08-06T00:00:00Z", "method": "10 specialist subagent audits (DB, quiz, flashcards/SRS, auth/sharing, imports, groups, frontend, tests, ops, adversarial) + parent verification (baseline commands, migration chain on temp DB, drift reproduction, code-path re-tracing). Read-only; no tracked files modified.", "outOfScope": "Arcade feature internals (may be removed) except where they affect the main app, shared dependencies, database integrity, build, or deployment.", "findings": [ { "id": "FIN-01", "sourceIds": ["DBAUD-01", "GRP-01"], "title": "Schema/migration drift: MaterialGroup table and Deck/QuizSet/ShareLink.groupId columns were never migrated — every fresh deployment's database is incompatible with the app", "severity": "Critical", "confidence": "Confirmed", "files": ["prisma/schema.prisma:33-34,61-62,128,133,196-208", "prisma/migrations/20260628011409_init/migration.sql:10-19,32-40,92-101", "src/generated/prisma/models/Deck.ts:44,54,64,201", "src/app/api/material-groups/route.ts:24,43,49", "package.json:6", "docker-entrypoint.sh:3"], "functions": ["createDeckFromImport", "createQuizSetFromImport", "toggleShareLink", "isContentSharedViaGroup", "MaterialGroup CRUD routes", "predev", "docker-entrypoint.sh"], "scenario": "Fresh clone → npm run dev (predev runs prisma migrate deploy) or docker-compose with empty volume → open any class page. GET /api/decks/list fails with 'no such column: Deck.groupId'; GET /api/material-groups fails with 'no such table: MaterialGroup'. Verified: temp DB built from the 4 migrations has no MaterialGroup table and no groupId columns; Prisma-shaped SELECTs fail with the exact errors above.", "expected": "migrate deploy produces a DB matching schema.prisma (groups feature present).", "actual": "The migration chain (the only DB-init path in dev and Docker) produces a DB missing the entire groups feature; even Deck/QuizSet/ShareLink reads fail because the generated client selects groupId.", "rootCause": "Commit 7af0935 changed schema.prisma (+31 lines) and updated the committed dev.db binary (a prisma db push artifact) but created no migration. The developer's db-push-synced local DB masks the drift; AGENTS.md rule 'change schema, then create a new migration' was violated.", "impact": "On any fresh install/deploy (new machine, new container volume, CI), the core app is non-functional: library pages fail, group/import/reorder/share operations error. No data loss on migration-tracked DBs; a db-push DB without _prisma_migrations would make migrate deploy fail with 'table already exists'.", "evidence": "git show 7af0935 --stat (dev.db 139264→155648 bytes, schema +31, no migration); git log --all -- prisma/migrations (only 4 migration commits); audit-results/tmp/inspect-db.cjs + reproduce-drift.cjs runs (no such column/table errors); grep MaterialGroup|groupId across migrations = 0 hits.", "testCoverage": "None. Only arcade + spacedRepetition tests exist; no migration/drift/integration test.", "fixDirection": "Create a new migration (prisma migrate dev --name add_material_groups) adding MaterialGroup table, groupId columns with FKs (SET NULL for Deck/QuizSet, CASCADE for ShareLink), and the ShareLink_groupId_key unique index; verify with prisma migrate diff --from-migrations --to-schema-datamodel (empty diff); add CI drift gate. Do NOT edit applied migrations.", "fixComplexity": "M", "regressionTest": "CI: migrate deploy on fresh temp DB → prisma migrate diff empty; smoke query prisma.deck.findFirst() + prisma.materialGroup.count().", "needsRuntimeConfirmation": false }, { "id": "FIN-02", "sourceIds": ["AUTH-01", "OPS-03"], "title": "Hardcoded fallback SESSION_SECRET enables full session-cookie forgery and authentication bypass when the env var is unset (the shipped docker-compose default)", "severity": "Critical", "confidence": "High-confidence inference", "files": ["src/lib/auth.ts:14-22", "src/proxy.ts:6-11,31-43", "docker-compose.yml:10", "src/services/authService.ts:61-67,128"], "functions": ["getSession", "createSession", "isAuthenticated", "proxy()", "login", "completePasswordReset"], "scenario": "docker compose up without SESSION_SECRET in .env (no .env.example exists, README silent) → compose interpolates an empty string → both lib/auth.ts and proxy.ts fall back to the public constant 'dev-session-secret-change-in-production-must-be-32-chars'. Anyone with the source seals {isAuthenticated:true, sessionGeneration:1} with that password (iron-session seal) and sets cookie study-app-session=; the proxy decrypts it successfully and grants full access to every protected route and API.", "expected": "Missing production secret should fail hard at startup.", "actual": "Silent fallback to a publicly-known secret; sessionGeneration defaults to '1' (only password reset bumps it), so a forged generation:1 cookie is valid in the default state.", "rootCause": "process.env.SESSION_SECRET || in two places with no startup validation; compose passes possibly-empty variable; no documented secret provisioning.", "impact": "Complete loss of confidentiality, integrity, availability of the entire single-user dataset (decks, cards, quizzes incl. rationales, attempts, progress, settings) on any deployment that omits the variable — read, modify, or wipe everything.", "evidence": "auth.ts:15 and proxy.ts:8-9 use the identical fallback; compose:10 passes ${SESSION_SECRET}; grep shows no other consumption or validation; iron-session seals with the password (verified in node_modules/iron-session).", "testCoverage": "None (no auth tests).", "fixDirection": "Refuse to boot in production when SESSION_SECRET is unset or equals the known fallback (throw at module load); compose: SESSION_SECRET: ${SESSION_SECRET:?must be set}; ship .env.example; document openssl rand -hex 32.", "fixComplexity": "S", "regressionTest": "Unit: sessionOptions/proxy reject missing/known secret under NODE_ENV=production; integration: cookie sealed with fallback rejected when a real secret is set.", "needsRuntimeConfirmation": true }, { "id": "FIN-03", "sourceIds": ["QUIZ-01", "ADV-01"], "title": "In-viewer 'Retake Missed' is persisted as a full attempt with a wrong, lower score and pollutes SEQUENTIAL progress", "severity": "High", "confidence": "Confirmed", "files": ["src/components/quizzes/QuizViewer.tsx:137,219-288,295-310", "src/app/api/quizzes/[id]/attempt/route.ts:33-43", "src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx:213-223"], "functions": ["QuizViewer.handleFinish", "QuizViewer.onRetake", "saveProgress", "POST /api/quizzes/[id]/attempt"], "scenario": "Finish a 10-question quiz missing 4; click 'Retake Missed (4)'; answer the 4 questions; Finish. The retake resets local state but never sets the retakeIds prop; handleFinish computes isPartialRetake=!!retakeIds=false; the server scores ALL questions (6 unanswered → 0) and persists e.g. 3/10 instead of 3/4. saveProgress also runs during the retake (guard reads the prop), writing retake-only answers into SEQUENTIAL progress.", "expected": "Partial retake scored only over submitted questions (domain invariant), maxScore = submitted count, no progress pollution.", "actual": "Full-attempt scoring with wrong low score; abandoned retakes leave progress pointing at the retake subset.", "rootCause": "Retake mode is tracked via a prop (set only by the page-level AttemptHistory flow), but the results-screen retake keeps state local and never lifts it; all three retake-dependent behaviors (saveProgress guard, isPartialRetake, progress DELETE) read the prop.", "impact": "Wrong persisted scores and history entries for the most common retake path; percentage drops and compounds on further retakes; resume can show only the retake subset.", "evidence": "QuizViewer.tsx:295-310 has no retakeIds setter (verified by reading); attempt/route.ts:40-43 filters only when isPartialRetake===true (verified); server cannot detect partial answers itself (adversarial review confirmed).", "testCoverage": "None.", "fixDirection": "Track retake mode in component state initialized from the prop (useState(!!retakeIds)), set it in onRetake; use it in saveProgress/isPartialRetake/DELETE; defense-in-depth: server validates isPartialRetake consistency with answer-set size.", "fixComplexity": "M", "regressionTest": "Component/integration test: full quiz → onRetake(missedIds) → answer → finish → assert POST body isPartialRetake===true and maxScore equals submitted question count.", "needsRuntimeConfirmation": true }, { "id": "FIN-04", "sourceIds": ["CARD-01", "IMPT-01", "QUIZ-06"], "title": "Stale card ids in saved progress are never filtered on resume (filterAndClampOrder is dead code) — deleting a card mid-session bricks the flashcard study session", "severity": "High", "confidence": "Confirmed", "files": ["src/lib/shuffle.ts:27-35", "src/components/flashcards/FlashcardViewer.tsx:42-44,73,174-207", "src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx:157-172", "src/services/cardService.ts:33-38"], "functions": ["filterAndClampOrder", "generateOrder", "FlashcardViewer (resume init)", "deleteCard", "GET /api/decks/[id]"], "scenario": "Study a deck to card N; open Manage; delete the current or any upcoming card; return to Study/reload. order still contains the deleted id; currentCard is undefined; the card area renders nothing, gradeCard early-returns, no skip UI exists → permanently stuck until Restart (which wipes progress). Quiz resume has the same latent gap (currently unreachable since question editing doesn't exist).", "expected": "Resume filters deleted ids and clamps the index — the cardService.ts:34-35 comment claims this.", "actual": "filterAndClampOrder has zero call sites (grep-verified); resume copies orderJson verbatim.", "rootCause": "The cleanup helper was implemented but never wired into the viewer's resume path; viewer initializes order once from props and never reconciles with the live cards list.", "impact": "Session becomes unusable after any card deletion affecting the current/upcoming position; user must restart, losing in-progress results. No data corruption.", "evidence": "grep filterAndClampOrder → definition + comment only; FlashcardViewer.tsx:42-44 parses raw orderJson (verified); gradeCard guard at :176.", "testCoverage": "None.", "fixDirection": "Call filterAndClampOrder on resume with (orderJson, existing card ids, currentIndex); also filter redoMissed/restartFullSet and results; add skip affordance as defense.", "fixComplexity": "S", "regressionTest": "Unit test filterAndClampOrder; component test: resume with stale id at ≤ currentIndex → filtered order, clamped index, card renders.", "needsRuntimeConfirmation": true }, { "id": "FIN-05", "sourceIds": ["OPS-01"], "title": "Port mismatch: container listens on 3726 but docker-compose publishes 3000:3000 — production deployment is unreachable", "severity": "High", "confidence": "Confirmed", "files": ["Dockerfile:19,34", "docker-compose.yml:6-7", ".next/standalone/server.js:8"], "functions": ["Next standalone startServer (PORT env)", "compose port publishing"], "scenario": "docker compose -f docker-compose.yml up on a server → entrypoint exec node server.js → listens on 0.0.0.0:3726 (ENV PORT=3726) → compose publishes host 3000 → container 3000 where nothing listens → curl http://server:3000 → connection refused. Dev path works only because the override runs npm run dev (PORT unset → 3000).", "expected": "Published port reaches the app.", "actual": "No listener on container port 3000; AGENTS.md already flags the mismatch ('verify the mapping') but the repo ships broken.", "rootCause": "Dockerfile PORT=3726 chosen for the image; compose port mapping never updated; EXPOSE is metadata only.", "impact": "Production deployment totally unreachable via the documented path — complete service outage.", "evidence": "server.js:8 (parseInt(process.env.PORT,10)||3000); Dockerfile:19; compose:6-7 (verified by reading).", "testCoverage": "None (CI builds but never runs the image).", "fixDirection": "Map '3726:3726' in compose (or drop ENV PORT); add healthcheck on the real port; document in README.", "fixComplexity": "S", "regressionTest": "CI smoke: run image with compose mapping, curl the published port, expect HTTP 200.", "needsRuntimeConfirmation": false }, { "id": "FIN-06", "sourceIds": ["OPS-06"], "title": "No .dockerignore: building on a Windows host injects win32 native modules (better-sqlite3, argon2) into the Linux image", "severity": "High", "confidence": "High-confidence inference", "files": ["Dockerfile:10-11,25", "(missing .dockerignore)", "next.config.ts:3-6"], "functions": ["docker build context", "COPY . ."], "scenario": "On this Windows machine: docker build . → context includes local node_modules (win32 better_sqlite3.node PE32+ DLL verified) and .next → builder's COPY . . overwrites the Linux deps from the deps stage → next build traces win32 natives into .next/standalone → the Linux runner image contains Windows binaries → ERR_DLOPEN_FAILED at runtime or build failure. CI (Linux) masks it entirely.", "expected": "Linux-built natives in the Linux image.", "actual": "Build-host platform leaks into the image when building from Windows.", "rootCause": "No .dockerignore; COPY . . after COPY --from=deps node_modules.", "impact": "Locally built production image is broken or fails to build; context bloat (node_modules, .next, .git).", "evidence": "Dockerfile COPY order (verified); local node_modules contains win32 binaries (adversarial review verified PE32+ via file); no .dockerignore (glob).", "testCoverage": "None.", "fixDirection": "Add .dockerignore (node_modules, .next, .git, .env*, data, audit-results, *.db*).", "fixComplexity": "S", "regressionTest": "CI assertion: container's better_sqlite3.node is ELF, not PE32+.", "needsRuntimeConfirmation": true }, { "id": "FIN-07", "sourceIds": ["OPS-08"], "title": "docker-compose.override.yml auto-merges on plain 'docker compose up' — the production command silently runs dev mode", "severity": "High", "confidence": "Confirmed", "files": ["docker-compose.override.yml:3-12", "docker-compose.yml:2-14"], "functions": ["Compose override auto-merge", "npm run dev"], "scenario": "Operator on the server runs the conventional 'docker compose up -d' with the repo present → Compose v2 auto-merges docker-compose.override.yml → build target deps, command npm run dev, NODE_ENV=development, bind mount → production runs dev server with hot reload and dev semantics. Running with -f docker-compose.yml instead hits FIN-05 (port mismatch). Every compose invocation is misconfigured.", "expected": "One documented production command.", "actual": "Two broken paths: dev-mode-in-production (silent) or unreachable (port mismatch).", "rootCause": "Dev override committed under the conventional auto-merge filename with no -f documentation.", "impact": "Unpredictable runtime behavior in production; dev-mode performance/logging; production bugs masked.", "evidence": "Override file contents (verified); standard Compose v2 auto-merge semantics; no README instructions (stock create-next-app).", "testCoverage": "None.", "fixDirection": "Rename to docker-compose.dev.yml and document COMPOSE_FILE/-f usage; document the exact production command.", "fixComplexity": "S", "regressionTest": "docker compose config for the documented production invocation must show production settings.", "needsRuntimeConfirmation": false }, { "id": "FIN-08", "sourceIds": ["AUTH-04", "ADV-09"], "title": "First-login provisioning takeover: whoever logs in first becomes admin; ADMIN_PASSWORD_HASH env is dead config", "severity": "Medium", "confidence": "High-confidence inference", "files": ["src/services/authService.ts:91-108", "src/app/api/auth/setup-status/route.ts:6-17", "docker-compose.yml:11", "src/lib/validation/authSchemas.ts"], "functions": ["login", "GET /api/auth/setup-status"], "scenario": "App reachable before the owner's first login: attacker polls the public /api/auth/setup-status until setupRequired:true, then POSTs any 8+ char password → becomes admin; owner's first login later fails. ADMIN_PASSWORD_HASH passed in compose is never read by any code (grep-verified).", "expected": "Password seeded from env or setup bound to the deployment; env var consumed.", "actual": "Provisioning is 'whoever logs in first'; the intended seeding env var is ignored.", "impact": "Full account takeover if the instance is exposed pre-setup; misleading deployment config.", "evidence": "authService.ts:97-101 first-login provisioning; setup-status public; grep ADMIN_PASSWORD_HASH → compose only.", "testCoverage": "None.", "fixDirection": "Consume ADMIN_PASSWORD_HASH at startup (seed Setting + mark setupRequired false) or require a one-time setup token; document firewall-until-setup.", "fixComplexity": "S-M", "regressionTest": "Boot with ADMIN_PASSWORD_HASH set → setup-status false, only that password works.", "needsRuntimeConfirmation": true }, { "id": "FIN-09", "sourceIds": ["AUTH-02"], "title": "Proxy auth bypass for any path containing a dot — latent authorization gap in every API route", "severity": "Medium", "confidence": "High-confidence inference", "files": ["src/proxy.ts:22-28,48-49"], "functions": ["proxy()"], "scenario": "GET /api/decks/.json → proxy sees '.' → passes through without session check → the [id] segment routes to the handler. Today it 404s (UUID lookups miss); any future route with non-UUID keys (numeric ids, slugs) becomes unauthenticated instantly.", "expected": "Every protected path requires a valid session.", "actual": "pathname.includes('.') bypasses the check entirely (asset convenience rule written too broadly).", "rootCause": "Static-asset rule not scoped to asset paths or an allowlist.", "impact": "Latent authorization bypass primitive; inconsistent 404-vs-redirect UX masking the gap.", "evidence": "proxy.ts:25 (verified by reading); compiled middleware contains the same logic (.next artifacts, adversarial review).", "testCoverage": "None.", "fixDirection": "Replace includes('.') with an explicit allowlist (favicon, known assets) or exclude /api/* from the bypass.", "fixComplexity": "S", "regressionTest": "Unauthenticated requests with dot-suffixed ids (.json, .rsc, %2E) → redirect/401, not handler execution.", "needsRuntimeConfirmation": true }, { "id": "FIN-10", "sourceIds": ["AUTH-03"], "title": "Password-reset endpoints abuseable: token overwrite DoS, spoofable x-forwarded-for rate limits, unthrottled complete", "severity": "Medium", "confidence": "High-confidence inference", "files": ["src/app/api/auth/password-reset/request/route.ts:5-24", "src/app/api/auth/password-reset/verify/route.ts:6-25", "src/app/api/auth/password-reset/complete/route.ts:5-24", "src/services/authService.ts:132-154", "src/lib/rateLimiter.ts:10-36"], "functions": ["requestPasswordReset", "verifyPasswordResetToken", "completePasswordReset", "checkRateLimit"], "scenario": "Owner requests reset → token T valid 15 min. Attacker POSTs request repeatedly with rotating x-forwarded-for headers → each call upserts the token record, killing T; log flooded with token lines; verify is rate-limited per spoofable IP; complete has no rate limit at all.", "expected": "Token issuance throttled globally, outstanding token preserved or invalidated with delay.", "actual": "Per-IP in-memory limits keyed on a client-controlled header; token overwrite per request; complete unlimited.", "impact": "Account-recovery DoS (matters once FIN-02 is fixed); log flooding; CPU/DB load.", "evidence": "authService.ts:146-150 upsert; routes' header trust (verified).", "testCoverage": "None.", "fixDirection": "DB-backed global rate limits; refuse minting while one is outstanding; rate-limit complete; document trusted-proxy requirement.", "fixComplexity": "M", "regressionTest": "Spoofed-IP parallel requests → only N tokens; outstanding token survives; complete 429 past threshold.", "needsRuntimeConfirmation": true }, { "id": "FIN-11", "sourceIds": ["QUIZ-02"], "title": "Attempt route stores unvalidated answersJson; duplicate option ids inflate SATA credit; malformed shapes cause 500s", "severity": "Medium", "confidence": "High-confidence inference", "files": ["src/app/api/quizzes/[id]/attempt/route.ts:24-60", "src/lib/scoring.ts:21-27"], "functions": ["POST /api/quizzes/[id]/attempt", "scoreQuestion"], "scenario": "Crafted request: SATA with correct [A,B], submit answersJson {\"qid\":[\"A\",\"A\"]} → correctSelected=2 → full credit for one distinct option selected twice; answersJson 'null' → Object.keys(null) throws → 500; raw body persisted verbatim.", "expected": "Server validates ids against the quiz set, dedupes, rejects malformed shapes with 4xx.", "actual": "Only JSON parseability checked; duplicates double-count; garbage persisted forever.", "impact": "Score integrity only exploitable via crafted HTTP (single-user app → low practical impact); corrupt attempt data persists.", "evidence": "attempt/route.ts:27-30,58; scoring.ts counts occurrences (verified).", "testCoverage": "None.", "fixDirection": "Zod schema for answersJson (Record, ids verified, dedupe, reject unknown); persist validated object.", "fixComplexity": "M", "regressionTest": "scoreQuestion with duplicate selections; API tests with unknown ids, 'null', arrays → 400/422.", "needsRuntimeConfirmation": false }, { "id": "FIN-12", "sourceIds": ["QUIZ-03", "FE-04"], "title": "Quiz Finish double-submit creates duplicate attempts; failure path gives no user feedback", "severity": "Medium", "confidence": "Confirmed", "files": ["src/components/quizzes/QuizViewer.tsx:219-288,506-513"], "functions": ["QuizViewer.handleFinish"], "scenario": "Double-click 'Finish Quiz' on the last question → two identical QuizAttempt rows; attempt history inflated. On POST failure only console.error runs; the button stays active.", "expected": "One attempt per completion with in-flight guard.", "actual": "No guard (per-question submit has submittingQuestionRef; finish has none); error path silent.", "impact": "Duplicate history entries, confusing retake stats.", "evidence": "QuizViewer.tsx:257-287 no guard ref; button not disabled (verified).", "testCoverage": "None.", "fixDirection": "finishingRef/isFinishing state; disable button in flight; visible error state on failure.", "fixComplexity": "S", "regressionTest": "Double-click Finish with delayed mocked POST → exactly one POST.", "needsRuntimeConfirmation": false }, { "id": "FIN-13", "sourceIds": ["QUIZ-04"], "title": "Progress PATCH can land after Finish's DELETE — stale 'Continue' row reappears for a completed quiz", "severity": "Medium", "confidence": "High-confidence inference", "files": ["src/components/quizzes/QuizViewer.tsx:148-159,271-281", "src/app/api/progress/route.ts:49-66"], "functions": ["QuizViewer.saveProgress", "QuizViewer.handleFinish", "DELETE /api/progress"], "scenario": "On the final question a non-awaited PATCH fires; user immediately clicks Finish; POST attempt succeeds; DELETE progress runs. If the PATCH lands after the DELETE (or DELETE fails), StudyProgress is recreated → quiz list shows 'Continue' for a completed quiz → resume → re-finish → duplicate attempt.", "expected": "Attempt persistence and progress cleanup coordinated.", "actual": "Three uncoordinated HTTP calls; no transaction, no sequencing.", "impact": "Intermittent stale progress, duplicate attempts.", "evidence": "QuizViewer.tsx:159 .catch(() => {}) swallows PATCH failure; DELETE is a separate request (verified).", "testCoverage": "None.", "fixDirection": "POST /attempt deletes the matching SEQUENTIAL progress row in the same Prisma transaction server-side.", "fixComplexity": "M", "regressionTest": "API test: POST attempt then assert progress row gone (transactional).", "needsRuntimeConfirmation": true }, { "id": "FIN-14", "sourceIds": ["CARD-02", "FE-05", "QUIZ-09"], "title": "Fire-and-forget, unsequenced progress autosaves: out-of-order PATCHes regress resume state; multi-tab last-write-wins loses answers", "severity": "Medium", "confidence": "High-confidence inference", "files": ["src/components/quizzes/QuizViewer.tsx:133-160", "src/components/flashcards/FlashcardViewer.tsx:265-294", "src/services/progressService.ts:36-64", "src/app/api/progress/route.ts:26-47"], "functions": ["saveProgress", "upsertProgress", "PATCH /api/progress"], "scenario": "On a slow connection, grade cards or submit answers rapidly: each action fires a non-awaited full-state PATCH; if PATCH(n) resolves after PATCH(n+1) the server stores the older index/results → resume point and tallies regress; closing the tab mid-flight loses the last grade. Two tabs editing the same quiz → later write clobbers the other tab's answers.", "expected": "Latest state wins; writes ordered.", "actual": "Last arrival wins; no sequence/version; no beforeunload flush.", "impact": "Silent resume-point regressions, lost answers, possibly wrong final scores.", "evidence": "FlashcardViewer.tsx:282-293 fetch().catch(() => {}); progressService unconditional update (verified).", "testCoverage": "None.", "fixDirection": "Serialize/queue saves (latest snapshot), add monotonic sequence or updatedAt compare-and-set server-side, flush on pagehide.", "fixComplexity": "M", "regressionTest": "Out-of-order PATCH simulation → only latest sequence persisted.", "needsRuntimeConfirmation": true }, { "id": "FIN-15", "sourceIds": ["CARD-03", "ADV-07"], "title": "Completed flashcard session is never persisted as complete — resume re-shows the last (already graded) card instead of the summary", "severity": "Medium", "confidence": "Confirmed", "files": ["src/components/flashcards/FlashcardViewer.tsx:61-65,196-203"], "functions": ["FlashcardViewer completed init", "gradeCard completion path"], "scenario": "Finish the last card → saveProgress stores currentIndex = order.length - 1 → reload: completed = currentIndex >= order.length is false → the last card is re-presented; re-grading it overwrites the recorded result.", "expected": "Reload of a completed session shows the 'Set complete' summary.", "actual": "Last card re-presented.", "impact": "Confusing resume; minor result-map overwrite on re-grade.", "evidence": "Lines 61-65 vs 196-203 (verified).", "testCoverage": "None.", "fixDirection": "Persist an explicit completed flag or save currentIndex = order.length on completion.", "fixComplexity": "S", "regressionTest": "Viewer init with currentIndex === orderJson.length shows summary.", "needsRuntimeConfirmation": true }, { "id": "FIN-16", "sourceIds": ["CARD-04"], "title": "SRS review endpoint enforces membership only — no due-date or newCardsPerDay enforcement server-side", "severity": "Medium", "confidence": "Confirmed", "files": ["src/services/spacedRepetitionService.ts:406-447,388-404", "src/app/api/spaced-repetition-sets/[id]/reviews/route.ts:6-22"], "functions": ["reviewCard", "POST /api/spaced-repetition-sets/[id]/reviews"], "scenario": "Direct POST can (a) introduce a brand-new card after the daily limit is exhausted (firstReviewedAt counts toward introducedToday), and (b) review a not-yet-due card early (FSRS reschedules, compressing the interval). The UI enforces limits only through queue construction.", "expected": "Server enforces the same queue rules the UI shows.", "actual": "reviewCard checks only set membership and expectedStateVersion.", "impact": "'New cards per day' guarantee is not data-level; future-dated cards can be advanced early (single-user: crafted requests/other tabs).", "evidence": "reviewCard 414-420 membership-only (verified).", "testCoverage": "None.", "fixDirection": "Reject if no state and newCardsAvailable<=0, or state exists and not due (outside learn-ahead).", "fixComplexity": "M", "regressionTest": "Service test: new card with limit 0 → 400/409; not-due review → 400/409.", "needsRuntimeConfirmation": false }, { "id": "FIN-17", "sourceIds": ["CARD-05", "ADV-02"], "title": "Unbounded DB scans: every activity fetch reads the entire StudyActivity table; class polling loads all SRS card states every 60 seconds", "severity": "Medium", "confidence": "Confirmed", "files": ["src/services/activityService.ts:65-68", "src/services/spacedRepetitionService.ts:54-142,153-220", "src/components/ui/Navbar.tsx:42-53", "src/services/classService.ts:11-22"], "functions": ["getActivitySummary", "getSetSummary", "getStudyAvailabilityByClass", "listClasses", "Navbar.refreshDueCards"], "scenario": "Every grade/review/quiz submit inserts a StudyActivity row; GET /api/activity reads the whole table; every page load and every 60 s the Navbar polls /api/classes which loads all sets + all card states for all classes. Query cost grows linearly forever.", "expected": "Bounded, date-filtered queries.", "actual": "Client-side aggregation over unfiltered findMany; N+1 per set.", "impact": "Progressive page-load/nav lag in a long-lived self-hosted DB.", "evidence": "activityService.ts:65-68 no where clause; Navbar interval 60s (verified).", "testCoverage": "None.", "fixDirection": "Date-filter StudyActivity, SQL-side aggregation, limit state scans to needed fields, cache availability.", "fixComplexity": "M", "regressionTest": "Activity summary with >371-day-old rows returns same result; query-shape assertion.", "needsRuntimeConfirmation": false }, { "id": "FIN-18", "sourceIds": ["FE-01", "GRP-03", "FE-02", "GRP-05"], "title": "Library optimistic mutations are fire-and-forget: reorder/delete/rename never check res.ok, never roll back; cross-group reorder omits the origin group from the payload", "severity": "Medium", "confidence": "Confirmed", "files": ["src/app/(protected)/[classSlug]/flashcards/page.tsx:244-295,313-372", "src/app/(protected)/[classSlug]/quizzes/page.tsx:231-282,311-374", "src/components/flashcards/CardManager.tsx:34-72"], "functions": ["handleDragEnd", "handleDeleteGroup", "handleDeleteDeck", "handleRenameDeck", "saveEdit", "deleteCard"], "scenario": "Drag a deck while offline → list keeps the new order; reload → order reverts silently (no error). Cross-group drag: newItems[oldIndex].groupId = targetGroupId mutates the object shared with activeDeck before affectedGroups is computed → origin group excluded from the PATCH payload → sortOrder gaps. Rename/delete fetches ignore res.ok; CardManager clears the editor even on failure.", "expected": "Rollback to server truth + error message on failure.", "actual": "Fire-and-forget fetches; in-place mutation; silent divergence until reload.", "impact": "Lost operations without feedback; sortOrder gaps; phantom state. (Adversarial review: no visible break for single user → downgraded from High; the mutation-before-read payload bug is real.)", "evidence": "flashcards/page.tsx:363-367 no await/catch; :343 mutation before :352 read (verified); SpacedRepetitionSets.tsx:277 shows the correct rollback pattern.", "testCoverage": "None.", "fixDirection": "Compute payload before mutating; await PATCH; on failure refetch or revert; check res.ok everywhere; disable drags while saving.", "fixComplexity": "M", "regressionTest": "Mock failing reorder → state refetched/reverted; unit test affectedGroups includes origin group.", "needsRuntimeConfirmation": true }, { "id": "FIN-19", "sourceIds": ["GRP-02", "CARD-07", "AUTH-06"], "title": "Reorder/create endpoints trust client groupId and sortOrder — no class/type/existence/complete-set validation; cross-class group membership can expose content in another class's share link", "severity": "Medium", "confidence": "Confirmed", "files": ["src/lib/validation/reorderSchemas.ts:3-11", "src/app/api/decks/reorder/route.ts:7-26", "src/app/api/quizzes/reorder/route.ts:7-26", "src/app/api/decks/route.ts:12,31", "src/services/deckService.ts:39-48", "src/services/quizService.ts:44-53", "src/app/shared/[classSlug]/[type]/[token]/page.tsx:64-83"], "functions": ["PATCH /api/decks/reorder", "PATCH /api/quizzes/reorder", "createDeckFromImport", "createQuizSetFromImport", "SharedPage"], "scenario": "(a) Crafted reorder assigns a deck from class A to a QUIZ-type group (or a class-B group) — FK passes, deck disappears from the library. (b) Partial reorder payloads leave duplicate sortOrders. (c) A deck assigned to another class's group appears in that group's public share link; the shared page validates only the group's class.", "expected": "Server validates membership/type/class and renumbers authoritatively.", "actual": "Blind write-through of client-computed values; shape-only Zod schema.", "impact": "Content becomes invisible; ambiguous ordering; cross-class content can leak into a group share link (privacy nuance for a single user).", "evidence": "reorderSchemas shape-only; routes' unconditional $transaction (verified).", "testCoverage": "None.", "fixDirection": "Server-side validation in reorder routes (same class, existing group of matching type, renumber 0..n-1, reject duplicates); validate groupId on create; per-item class check in SharedPage.", "fixComplexity": "M", "regressionTest": "Cross-class groupId rejected; QUIZ group for deck rejected; partial set renumbered; shared group page with cross-class item → notFound.", "needsRuntimeConfirmation": true }, { "id": "FIN-20", "sourceIds": ["FE-03"], "title": "Shared quiz session resets and options reshuffle on any parent re-render (unstable inline quiz prop object)", "severity": "Medium", "confidence": "Confirmed", "files": ["src/app/shared/[classSlug]/[type]/[token]/SharedViewer.tsx:149-159", "src/components/quizzes/QuizViewer.tsx:64-130"], "functions": ["SharedViewer render", "QuizViewer init effect"], "scenario": "Open a shared quiz link; any SharedViewer re-render (auto after setHasSavedSession, topics toggle, view switch) creates a new quiz object → QuizViewer's init effect re-runs → shuffledOptions regenerate (options move mid-question) and, without a saved localStorage session, a fresh random question order is generated (the current question changes).", "expected": "Stable question/option order for the whole session.", "actual": "Options (and possibly the current question) reshuffle after unrelated UI toggles.", "impact": "Confusing UX; mis-reading moved options; answers tracked by id so scores stay correct.", "evidence": "SharedViewer.tsx:151-156 inline object literal (verified); QuizViewer deps [quiz, retakeIds, isShared].", "testCoverage": "None.", "fixDirection": "Memoize the quiz object (useMemo on data.id) or make init mount-only/state-lazy.", "fixComplexity": "S", "regressionTest": "Render shared QuizViewer, answer, re-render parent → option order array unchanged.", "needsRuntimeConfirmation": true }, { "id": "FIN-21", "sourceIds": ["IMPT-02", "FE-08", "QUIZ-05", "ADV-10"], "title": "/api/progress PATCH is completely unvalidated and client JSON.parse of persisted progress is unguarded — malformed rows crash pages", "severity": "Medium", "confidence": "Confirmed", "files": ["src/app/api/progress/route.ts:26-47", "src/services/progressService.ts:18-65", "src/app/(protected)/[classSlug]/flashcards/page.tsx:55-67", "src/components/flashcards/FlashcardViewer.tsx:42-44,50-54,61-65"], "functions": ["PATCH /api/progress", "upsertProgress", "getProgressLabel", "FlashcardViewer init"], "scenario": "PATCH with contentType:'QUIZZ', mode:'weird', orderJson:'not json', currentIndex:-5 — all accepted and persisted (no Zod anywhere in the route). Malformed orderJson then crashes the flashcards list page at render (getProgressLabel JSON.parse) and the viewer at mount (unguarded useState initializers); bogus contentId → FK violation → uncaught 500. QuizViewer guards the same parse (QuizViewer.tsx:104-116); the flashcard paths do not.", "expected": "400 on invalid payloads; safe fallback on read.", "actual": "Garbage persisted; render-time SyntaxError white-screens pages.", "impact": "Page crash on the flashcards tab; latent trap for any future writer bug.", "evidence": "progress/route.ts imports no validator (verified); flashcards/page.tsx:58-61 bare JSON.parse; FlashcardViewer.tsx:43,52,63 bare parses.", "testCoverage": "None.", "fixDirection": "Zod schema for progress PATCH (enums, JSON-string validity, index >= 0); wrap client parses in try/catch with fallback (mirror QuizViewer).", "fixComplexity": "S", "regressionTest": "API: malformed orderJson → 400; render: corrupt progress row → page renders with fallback.", "needsRuntimeConfirmation": true }, { "id": "FIN-22", "sourceIds": ["OPS-02"], "title": "Entrypoint migrate-deploy chain is fragile: failure = crash loop with no recovery path; runner prisma CLI install is unverified and unpinned", "severity": "Medium", "confidence": "High-confidence inference", "files": ["docker-entrypoint.sh:2-4", "Dockerfile:16-36", "prisma.config.ts:7"], "functions": ["docker-entrypoint.sh", "RUN npm install prisma@^7.8.0"], "scenario": "set -e + npx prisma migrate deploy + exec node server.js. On a FRESH volume migrate succeeds (no drift detection — the DB is then incompatible per FIN-01, so the app 500s but the container 'runs'). On conflicting DB state (db-push DB, manual tampering, locked file) migrate fails → container exits → restart: unless-stopped → infinite crash loop, no documented recovery. The runner's unpinned npm install prisma@^7.8.0 (no lockfile, re-resolves the whole tree, runtime network dependency for npx) is plausible per Prisma 7 docs but never exercised by CI.", "expected": "Migration failures are observable and recoverable; image contents reproducible.", "actual": "Silent crash loop / broken-but-running container; no smoke test anywhere.", "impact": "Prolonged outage without diagnostics on secondary triggers; nondeterministic images.", "evidence": "entrypoint set -e (verified); adversarial review weakened the fresh-volume crash-loop framing (migrate succeeds there).", "testCoverage": "None (CI never runs the image).", "fixDirection": "Retry-with-backoff or one-shot init job; clear failure logs + documented recovery; install prisma pinned from the lockfile (or copy from builder); CI container smoke test.", "fixComplexity": "M", "regressionTest": "Container smoke: fresh volume → 200 and _prisma_migrations populated; corrupt volume → actionable log, no silent loop.", "needsRuntimeConfirmation": true }, { "id": "FIN-23", "sourceIds": ["OPS-05"], "title": "Non-reproducible installs: npm install (not ci) in deps; unpinned prisma re-install re-resolving the whole tree in the runner", "severity": "Medium", "confidence": "Confirmed", "files": ["Dockerfile:5,30", "package.json:38"], "functions": ["docker build stages"], "scenario": "deps stage npm install can silently mutate the lockfile; the runner's npm install prisma@^7.8.0 against the standalone package.json re-resolves all caret ranges, downloads native modules, and floats image content with the registry.", "expected": "Images reproducible from package-lock.json.", "actual": "Runner image content not a function of the repo lockfile.", "impact": "Nondeterministic images; invisible dependency drift (incl. prisma patch releases).", "evidence": "Dockerfile:5,30 (verified); standalone package.json is a full copy.", "testCoverage": "None.", "fixDirection": "npm ci in deps; pin prisma exact version or copy CLI from builder stage.", "fixComplexity": "M", "regressionTest": "Two builds from same commit → prisma --version identical.", "needsRuntimeConfirmation": false }, { "id": "FIN-24", "sourceIds": ["OPS-07"], "title": "No healthcheck, no backup, no documented recovery — outages are invisible to the orchestrator and the DB is a single point of failure", "severity": "Medium", "confidence": "Confirmed", "files": ["docker-compose.yml:5", "docker-entrypoint.sh:3", "Dockerfile:35"], "functions": ["compose orchestration", "entrypoint"], "scenario": "A hung-but-running app shows 'running' in docker ps while /login 500s; a corrupt study.db means years of study data with no backup and no documented recovery procedure; no WAL mode (default rollback journal).", "expected": "Observable health + backup + recovery path.", "actual": "None of the three.", "impact": "Prolonged undetected outages; permanent data loss on disk failure.", "evidence": "compose has no healthcheck; entrypoint no retry (verified).", "testCoverage": "None.", "fixDirection": "Add healthcheck (wget -qO- http://127.0.0.1:3726/login); document volume backup; consider WAL + backup script.", "fixComplexity": "S-M", "regressionTest": "Compose healthcheck flips unhealthy when entrypoint fails.", "needsRuntimeConfirmation": true }, { "id": "FIN-25", "sourceIds": ["OPS-10", "TEST-10"], "title": "CI builds and pushes the image without running tests, lint, prisma validate, or a container smoke test — every deployment-breaking defect ships with a green pipeline", "severity": "Medium", "confidence": "Confirmed", "files": [".forgejo/workflows/build.yml:12-25", "package.json:6-11", "vitest.config.ts"], "functions": ["CI workflow"], "scenario": "Any of FIN-01/02/05/06/07 land on main → CI is green (docker build && push only) → broken image tagged latest. No PR pipeline, no test step, no coverage gate.", "expected": "Tests, lint, prisma validate, and a container smoke gate the image.", "actual": "None run; pipeline reports success while running zero tests.", "impact": "Broken production artifacts shipped; regressions invisible until deployment.", "evidence": "build.yml steps (verified); package.json test script never referenced by any workflow.", "testCoverage": "The gap itself.", "fixDirection": "Add npm ci && npm test && lint && prisma validate jobs plus a container smoke (curl /login on fresh volume); tag by commit SHA.", "fixComplexity": "M", "regressionTest": "CI fails when the container doesn't return 200 on a fresh volume (catches FIN-01/FIN-05).", "needsRuntimeConfirmation": false }, { "id": "FIN-26", "sourceIds": ["QUIZ-07"], "title": "SATA scoring divides by correctIds.length with no zero-guard → NaN scores", "severity": "Low", "confidence": "Confirmed", "files": ["src/lib/scoring.ts:27", "src/lib/validation/importSchemas.ts:16-18"], "functions": ["scoreQuestion (SATA branch)"], "scenario": "A SATA question with zero correct options → 0/0 → NaN; scoreQuiz sums NaN; persisted score NaN. Unreachable through the current import path (Zod enforces >=1 correct) but unguarded at score time — any future content-edit feature or DB drift corrupts scores silently.", "expected": "Defensive 0 for degenerate questions.", "actual": "NaN propagates.", "impact": "Latent corruption risk; none today.", "evidence": "scoring.ts:27 no guard (verified).", "testCoverage": "None.", "fixDirection": "if (correctIds.length === 0) return 0;", "fixComplexity": "S", "regressionTest": "scoreQuestion with 0-correct SATA → 0; scoreQuiz finite.", "needsRuntimeConfirmation": false }, { "id": "FIN-27", "sourceIds": ["QUIZ-08"], "title": "Historical review and category breakdown recompute scores from current content, not the stored attempt", "severity": "Low", "confidence": "Confirmed", "files": ["src/components/quizzes/QuizResults.tsx:32-52", "src/components/quizzes/CategoryBreakdown.tsx:17-35"], "functions": ["QuizResults", "CategoryBreakdown"], "scenario": "If option correctness/type/set ever differs from attempt time (content editing is added later), per-question review scores and category bars diverge from the persisted total; 'Retake Missed' ids derive from current content. Today content is immutable so the recompute matches.", "expected": "Render the attempt as scored.", "actual": "Only total + answersJson stored; everything per-question recomputed.", "impact": "Latent historical-corruption display bug; benign today.", "evidence": "QuizResults.tsx:35-52 recompute vs :54 persisted banner (verified).", "testCoverage": "None.", "fixDirection": "Persist per-question points snapshot at submit time; render stored points with fallback for legacy rows.", "fixComplexity": "M", "regressionTest": "Score quiz, flip an option's isCorrect, re-render → per-question scores still match stored attempt.", "needsRuntimeConfirmation": false }, { "id": "FIN-28", "sourceIds": ["IMPT-03"], "title": "Card add/edit endpoints bypass Zod: empty/whitespace strings storable; CreateTab silently drops incomplete cards", "severity": "Low", "confidence": "Confirmed", "files": ["src/app/api/cards/[id]/route.ts:11-16", "src/app/api/decks/[id]/cards/route.ts:11-25", "src/components/flashcards/CardManager.tsx:34-48", "src/components/import/CreateTab.tsx:46-50"], "functions": ["PATCH /api/cards/[id]", "POST /api/decks/[id]/cards", "saveEdit", "handleCreate"], "scenario": "Edit a card, clear both textareas, Save → 200 with blank front persisted; PATCH with {} → silent no-op 200; CreateTab with 3 cards where one is blank → 2-card deck created with no warning.", "expected": "400 on empty content; no silent drops.", "actual": "Empty strings persisted; silent card dropping.", "impact": "Blank/garbage cards in decks; misleading save success.", "evidence": "cards/[id]/route.ts:11-16 (verified); CreateTab.tsx:46 filter.", "testCoverage": "None.", "fixDirection": "Shared cardContentSchema with safeParse in both endpoints; surface incomplete-card errors in CreateTab.", "fixComplexity": "S", "regressionTest": "API: PATCH card with '' → 400; CreateTab 3-cards-1-blank → error, nothing created.", "needsRuntimeConfirmation": false }, { "id": "FIN-29", "sourceIds": ["IMPT-04"], "title": "No payload size/string-length/array-length caps on import schemas and routes; whitespace-only names accepted", "severity": "Low", "confidence": "Confirmed", "files": ["src/lib/validation/importSchemas.ts:3-45", "src/app/api/decks/route.ts:5-25", "src/app/api/quizzes/route.ts:5-25", "next.config.ts:3-6"], "functions": ["flashcardImportSchema", "quizImportSchema", "POST /api/decks|quizzes"], "scenario": "A 20 MB JSON with 100k cards → minutes-long UI freeze (per-keystroke parse+repair+validate pipeline in ImportTab), huge transaction; ' ' as deckName passes (no trim); very long strings bloat rows. SRS schemas cap at 120/5000 chars — import schemas don't.", "expected": "Bounded inputs consistent with SRS schemas.", "actual": "Unbounded everything; no body-size guard.", "impact": "Self-DoS, DB bloat, visually-empty names.", "evidence": "importSchemas.ts has no .max()/.trim() (verified); spacedRepetitionSchemas.ts:4-5 has caps.", "testCoverage": "None.", "fixDirection": "Add trim/min/max to strings, .max() on arrays, description caps, content-length guard, debounce ImportTab.", "fixComplexity": "S-M", "regressionTest": "Schema tests: whitespace names rejected, oversized arrays/strings rejected.", "needsRuntimeConfirmation": false }, { "id": "FIN-30", "sourceIds": ["IMPT-05"], "title": "SATA constraint mismatch: LLM instructions require >=2 correct options, schema enforces only >=1", "severity": "Low", "confidence": "Confirmed", "files": ["src/lib/validation/importSchemas.ts:16-24", "src/services/settingsService.ts:38-39"], "functions": ["quizImportSchema", "getDefaultInstructions"], "scenario": "LLM returns a SATA question with exactly one correct:true → import succeeds despite instructions saying 'two or more options with correct: true'. Scoring still works (single correct), so no corruption — doc/schema drift.", "expected": "Rejected per documented rule.", "actual": "Accepted.", "impact": "Semantic drift; 1-correct SATA graded with partial-credit formula.", "evidence": "importSchemas refine >=1 vs settingsService.ts:38-39 (verified).", "testCoverage": "None.", "fixDirection": "Per-type refine: sata → correctCount >= 2 (or align instructions).", "fixComplexity": "S", "regressionTest": "Schema: SATA with 1 correct fails; 2+ passes; MC 0/2 fails.", "needsRuntimeConfirmation": false }, { "id": "FIN-31", "sourceIds": ["IMPT-06"], "title": "Unvalidated name/groupId override fields on import POST routes throw 500s; PATCH routes mask all failures as 404", "severity": "Low", "confidence": "Confirmed", "files": ["src/app/api/decks/route.ts:12,27-32", "src/app/api/quizzes/route.ts:12,27-32", "src/app/api/decks/[id]/route.ts:22-33", "src/app/api/quizzes/[id]/route.ts:22-33"], "functions": ["POST /api/decks|quizzes", "PATCH /api/decks/[id]|quizzes/[id]"], "scenario": "POST with name:123 → TypeError: name.trim is not a function → unhandled 500; PATCH with wrong-typed body → 404 'Deck not found' for an existing deck (misleading diagnostics).", "expected": "400 with a validation message.", "actual": "500 / misleading 404.", "impact": "Error-handling quality only (UI never sends wrong types).", "evidence": "decks/route.ts:12,27-32 (verified).", "testCoverage": "None.", "fixDirection": "Validate the whole request envelope with Zod; narrow PATCH catch to P2025 for 404.", "fixComplexity": "S", "regressionTest": "POST numeric name → 400; PATCH wrong-typed body → 400 not 404.", "needsRuntimeConfirmation": false }, { "id": "FIN-32", "sourceIds": ["GRP-04"], "title": "Group deletion leaves duplicate sortOrder values in Uncategorized; no unique constraint and no tie-break → ambiguous, unstable order", "severity": "Low", "confidence": "Confirmed", "files": ["src/app/(protected)/[classSlug]/flashcards/page.tsx:270-275", "src/app/(protected)/[classSlug]/quizzes/page.tsx:257-262", "prisma/schema.prisma:24-39,52-67", "src/services/deckService.ts:5-7"], "functions": ["handleDeleteGroup", "DELETE /api/material-groups/[id]", "listDecksByClass", "listQuizSetsByClass"], "scenario": "Group A has decks sortOrder 0,1,2; Uncategorized has 0,1. Delete group A → SetNull moves rows without renumbering → duplicates; rendering ties resolve to SQLite scan order which can change after VACUUM. Dragging self-heals; imports into Uncategorized use max+1 leaving old collisions.", "expected": "Renumbered sequential sortOrders after delete.", "actual": "Stale group-relative values kept.", "impact": "Cosmetic unstable ordering after the common 'delete group' operation.", "evidence": "DELETE route plain delete; orderBy single-key (verified).", "testCoverage": "None.", "fixDirection": "Renumber in the delete route transaction; add deterministic orderBy tie-break (createdAt).", "fixComplexity": "S", "regressionTest": "Integration: delete group → all Uncategorized sortOrders unique and sequential.", "needsRuntimeConfirmation": true }, { "id": "FIN-33", "sourceIds": ["GRP-06"], "title": "Keyboard users cannot move items between groups; no drag announcements", "severity": "Low", "confidence": "Confirmed", "files": ["src/app/(protected)/[classSlug]/flashcards/page.tsx:298-311", "src/app/(protected)/[classSlug]/quizzes/page.tsx:285-309"], "functions": ["handleDragOver", "sensors"], "scenario": "KeyboardSensor allows within-group reorder, but cross-container keyboard moves require onDragOver to move the active item between containers; handleDragOver is a no-op → cross-group moves are pointer-only. Screen readers get no live announcements.", "expected": "Full DnD parity for keyboard.", "actual": "Within-group only.", "impact": "Accessibility gap; no data corruption.", "evidence": "Empty handleDragOver bodies (verified).", "testCoverage": "None.", "fixDirection": "Implement handleDragOver for keyboard container switches or add explicit 'Move to group' actions.", "fixComplexity": "M", "regressionTest": "Keyboard test moving an item across two groups.", "needsRuntimeConfirmation": true }, { "id": "FIN-34", "sourceIds": ["CARD-08"], "title": "Restart deletes progress fire-and-forget; a slow DELETE can remove the fresh session's newly created progress row", "severity": "Low", "confidence": "High-confidence inference", "files": ["src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx:42-62", "src/app/(protected)/[classSlug]/flashcards/page.tsx:117-125"], "functions": ["handleRestart", "DELETE /api/progress"], "scenario": "Click Restart → two unawaited DELETEs fire → viewer remounts → first grade PATCHes a new progress row → if a DELETE lands after the PATCH, the new row is deleted → next load shows no progress (session silently restarted).", "expected": "Restart clears old progress only.", "actual": "Delete-after-create race can remove the new row.", "impact": "Occasional unexpected session reset (small window).", "evidence": "[deckId]/page.tsx:46-61 .catch(() => {}) (verified).", "testCoverage": "None.", "fixDirection": "Await DELETEs before remount or use per-deck session ids so late DELETEs can't touch newer rows.", "fixComplexity": "S", "regressionTest": "Route-order simulation.", "needsRuntimeConfirmation": true }, { "id": "FIN-35", "sourceIds": ["CARD-09"], "title": "Concurrent first review of the same new card hits the unique constraint → unhandled generic 500 instead of 409", "severity": "Low", "confidence": "Confirmed", "files": ["src/services/spacedRepetitionService.ts:426-442", "src/lib/spacedRepetitionApi.ts:4-9"], "functions": ["reviewCard", "spacedRepetitionErrorResponse"], "scenario": "Two rapid reviews of a never-reviewed card (double-tap or two tabs): both see current=null, both create; loser hits P2002 → generic 500 'Spaced repetition request failed' (the update path maps this to 409 via optimistic concurrency; the create path doesn't).", "expected": "409 'already reviewed'.", "actual": "500 with no guidance.", "impact": "Confusing error in an edge double-submit; review still recorded by the winner.", "evidence": "reviewCard create path lacks the updateMany guard (verified).", "testCoverage": "None.", "fixDirection": "Catch P2002 → 409, or upsert-guard with retry.", "fixComplexity": "S", "regressionTest": "Two concurrent reviewCard calls → one 409.", "needsRuntimeConfirmation": false }, { "id": "FIN-36", "sourceIds": ["CARD-10"], "title": "'Previous card' during the 350 ms grade animation races the pending timeout — index jumps forward, result map inconsistent", "severity": "Low", "confidence": "High-confidence inference", "files": ["src/components/flashcards/FlashcardViewer.tsx:190-204,531-544"], "functions": ["gradeCard", "previous-card handler"], "scenario": "Grade a card, click Previous within 350 ms: click sets currentIndex-1; the pending timeout then fires setCurrentIndex(currentIndex+1) from a stale closure → jumps forward past the card the user navigated to; saveProgress writes results inconsistent with the visible card.", "expected": "Navigation wins.", "actual": "Timeout overwrites navigation.", "impact": "Transient wrong position/result map; self-corrects on next action.", "evidence": "Timeout uses closure currentIndex (verified).", "testCoverage": "None.", "fixDirection": "Disable prev during swipe or use functional setState/refs in the timeout.", "fixComplexity": "S", "regressionTest": "Component test simulating grade→previous→timeout sequence.", "needsRuntimeConfirmation": true }, { "id": "FIN-37", "sourceIds": ["CARD-11"], "title": "SRS set page never refreshes on window focus — stale membership after deck deletion in another tab", "severity": "Low", "confidence": "Confirmed", "files": ["src/components/spaced-repetition/SpacedRepetitionSets.tsx:167-192"], "functions": ["SpacedRepetitionSets.load"], "scenario": "Keep the SRS page open; delete a member deck in another tab → the UI still lists the deck (DB is cascade-clean); removing/dragging it yields 404 errors until manual reload. Navbar refreshes on focus; the sets page doesn't.", "expected": "UI reflects DB state.", "actual": "Mount-only load.", "impact": "Stale list; harmless 404 messages.", "evidence": "useEffect mount-only (verified); contrast Navbar.tsx:45.", "testCoverage": "None.", "fixDirection": "Add focus listener / deck-changed event to re-run load.", "fixComplexity": "S", "regressionTest": "Component-level focus-refresh test.", "needsRuntimeConfirmation": true }, { "id": "FIN-38", "sourceIds": ["CARD-06"], "title": "Day boundary hardcoded to Arizona (UTC-7): 'today', new-card limit, and streak roll over at 07:00 UTC for non-Arizona users", "severity": "Low", "confidence": "Confirmed", "files": ["src/lib/spacedRepetition.ts:157-161", "src/services/activityService.ts:28-30", "src/lib/spacedRepetition.test.ts:56-61"], "functions": ["getArizonaDayBounds", "getEffectiveDue", "toArizonaDateKey"], "scenario": "For a UTC+8 user the day flips at 15:00 local; due 'today' and the daily new-card allowance become available mid-afternoon and reset mid-afternoon. Internally consistent (tests assert the design); the label 'Arizona time' in ActivityBanner confirms intent — but it's never a setting.", "expected": "Configurable timezone.", "actual": "Fixed UTC-7 offset.", "impact": "Confusing day boundaries for non-Arizona users.", "evidence": "spacedRepetition.ts:157-161 (verified); tests codify it.", "testCoverage": "Yes — unit tests assert the current behavior.", "fixDirection": "Make the timezone a Setting (default America/Phoenix) threaded through the pure functions.", "fixComplexity": "M", "regressionTest": "Parameterize with another offset and assert boundaries.", "needsRuntimeConfirmation": false }, { "id": "FIN-39", "sourceIds": ["AUTH-05"], "title": "Proxy destroy() cookie-clear header is lost on redirect; page-level check ignores sessionGeneration", "severity": "Low", "confidence": "Confirmed", "files": ["src/proxy.ts:30-43", "src/app/(protected)/layout.tsx:10-13", "src/lib/auth.ts:53-56"], "functions": ["proxy()", "ProtectedLayout", "isAuthenticated"], "scenario": "Stale/tampered cookie → proxy redirects to /login, but session.destroy() attaches Set-Cookie to the discarded NextResponse.next() response, not the redirect → the browser keeps the bad cookie → every navigation redirects again until re-login. Layout backstop checks only isAuthenticated, not generation.", "expected": "Invalid cookie cleared on redirect.", "actual": "Clear header dropped; defense-in-depth inconsistency.", "impact": "Minor UX (redirect loop until re-login).", "evidence": "proxy.ts:30-42 (verified); iron-session destroy writes to the passed response.", "testCoverage": "None.", "fixDirection": "Build the redirect first, then getIronSession(request, redirectResponse) before returning; make isAuthenticated generation-aware.", "fixComplexity": "S", "regressionTest": "Integration: stale-generation cookie → redirect response carries Set-Cookie clearing study-app-session.", "needsRuntimeConfirmation": true }, { "id": "FIN-40", "sourceIds": ["AUTH-08"], "title": "Session cookie Secure flag off in the shipped compose (plain-HTTP deployment, no TLS story)", "severity": "Low", "confidence": "Confirmed", "files": ["src/lib/auth.ts:17-21", "docker-compose.yml:8-12"], "functions": ["sessionOptions"], "scenario": "Default docker compose up → secure=false → session cookie and login POST travel in cleartext on the LAN; any host can sniff and replay. SECURE_COOKIES=true on plain HTTP would break login entirely — the flag logic is correct; the deployment lacks TLS.", "expected": "HTTPS with secure cookies.", "actual": "Plain HTTP, no HSTS.", "impact": "Credential/session sniffing on shared networks; critical if port-forwarded.", "evidence": "auth.ts:18 (verified); compose sets no SECURE_COOKIES.", "testCoverage": "None.", "fixDirection": "Document/require a TLS proxy (Caddy/Traefik) + SECURE_COOKIES=true.", "fixComplexity": "S", "regressionTest": "Unit test flag computation across env combos.", "needsRuntimeConfirmation": true }, { "id": "FIN-41", "sourceIds": ["DBAUD-02", "AUTH-07"], "title": "/api/share accepts arbitrary targetType — repeatable junk all-NULL ShareLink rows; no CHECK constraints or enum validation anywhere", "severity": "Low", "confidence": "Confirmed", "files": ["src/app/api/share/route.ts:32-44", "src/services/shareService.ts:105-123", "prisma/schema.prisma:92-109,123-134"], "functions": ["toggleShareLink", "POST /api/share"], "scenario": "POST /api/share {targetType:'FOO', contentId:'x'} → toggleShareLink matches no branch → creates ShareLink with all three FKs null; SQLite unique indexes treat NULLs as distinct → unlimited junk rows; no cleanup path. StudyProgress has the same unvalidated contentType (FIN-21).", "expected": "Only DECK/QUIZ/GROUP with exactly one target.", "actual": "Any string accepted; junk rows persist.", "impact": "Data hygiene (owner-controlled); no privilege boundary crossed.", "evidence": "toggleShareLink fall-through create (verified).", "testCoverage": "None.", "fixDirection": "Zod enum validation at the route; optional CHECK constraints via raw migration.", "fixComplexity": "S", "regressionTest": "POST invalid targetType → 400, zero rows; GROUP twice → toggles to one row.", "needsRuntimeConfirmation": false }, { "id": "FIN-42", "sourceIds": ["DBAUD-03"], "title": "SQLite dev.db was committed in git history (8 commits) and remains in blobs on the LAN remote", "severity": "Low", "confidence": "High-confidence inference", "files": ["git history (dev.db blobs in b7ce314..7af0935)", ".git/config:8-10", ".gitignore:17"], "functions": ["git history"], "scenario": "Anyone with repo/server access can git show 7af0935:dev.db — a real SQLite DB containing StudyProgress rows (verified blob markers; no password hash found in the checked blobs, but content not fully enumerable read-only).", "expected": "DB files never in VCS.", "actual": "Present in history incl. the db-push drift variant.", "impact": "Personal study content exposure to anyone with repo/server access; conditional (no credentials found).", "evidence": "git log --all -- dev.db → 8 commits (verified).", "testCoverage": "n/a.", "fixDirection": "If the remote is ever shared: git filter-repo --path dev.db --invert-paths + force-push.", "fixComplexity": "S-M", "regressionTest": "git log --all -- dev.db empty after rewrite.", "needsRuntimeConfirmation": false }, { "id": "FIN-43", "sourceIds": ["OPS-09"], "title": ".gitignore gaps: production data/ directory and study.db* not ignored; scratch files (out.css, temp.css, test.css) tracked", "severity": "Low", "confidence": "Confirmed", "files": [".gitignore:16-20", "docker-compose.yml:13-14", "git ls-files"], "functions": ["git", "compose volume"], "scenario": "Running production compose from the repo creates ./data/study.db — not ignored → a careless 'git add .' commits the real production database. temp.css/test.css/out.css are tracked junk.", "expected": "Database artifacts never committable.", "actual": "Only /dev.db* guarded.", "impact": "Risk of leaking the entire study database to the Forgejo remote.", "evidence": ".gitignore contents vs compose volume path (verified).", "testCoverage": "n/a.", "fixDirection": "Add data/, /study.db*, audit-results/, .reasonix/; untrack the junk files.", "fixComplexity": "S", "regressionTest": "git check-ignore data/study.db returns the path.", "needsRuntimeConfirmation": false }, { "id": "FIN-44", "sourceIds": ["FE-07", "ADV-04"], "title": "Dashboard fetchClasses has no .catch and no res.ok check — unhandled rejection and misleading empty state (or render crash on error JSON)", "severity": "Low", "confidence": "Confirmed", "files": ["src/app/(protected)/page.tsx:30-37"], "functions": ["fetchClasses"], "scenario": "Network/server error on the dashboard → unhandled promise rejection → 'Your desk is ready' empty state despite existing classes; a 500 JSON error body stored into classes makes classes.map crash the render.", "expected": "Error banner + retry.", "actual": "Silent empty state / crash.", "impact": "Misleading UI after transient failures.", "evidence": "page.tsx:33-36 try/finally without catch (verified).", "testCoverage": "None.", "fixDirection": "Add catch → error state with retry; check res.ok before parsing.", "fixComplexity": "S", "regressionTest": "Reject /api/classes → error UI, not empty state.", "needsRuntimeConfirmation": false }, { "id": "FIN-45", "sourceIds": ["FE-09"], "title": "Effect fetches without .catch in GenerateTab and ShareMenu — unhandled rejections, degraded UI states", "severity": "Low", "confidence": "Confirmed", "files": ["src/components/import/GenerateTab.tsx:18-23", "src/components/ui/ShareMenu.tsx:38-48"], "functions": ["GenerateTab effect", "ShareMenu effect"], "scenario": "Settings/share fetch fails → unhandled rejection; GenerateTab textarea stays empty with no error (setInstructions(undefined) makes a controlled textarea temporarily uncontrolled); ShareMenu shows 'Enable link sharing' even when a share token exists server-side.", "expected": "Error state.", "actual": "Silent degraded UI.", "impact": "Minor UX confusion; console noise.", "evidence": "Missing .catch in both (verified).", "testCoverage": "None.", "fixDirection": "Add .catch → error message/retry; validate data.value before setState.", "fixComplexity": "S", "regressionTest": "Reject fetches → no unhandled rejection, error message shown.", "needsRuntimeConfirmation": false }, { "id": "FIN-46", "sourceIds": ["FE-10"], "title": "Cross-class navigation fetch race: slow response from class A can render class A's data under class B's header", "severity": "Low", "confidence": "High-confidence inference", "files": ["src/app/(protected)/[classSlug]/flashcards/page.tsx:205-241", "src/app/(protected)/[classSlug]/quizzes/page.tsx:192-228"], "functions": ["fetchAll", "init effect"], "scenario": "Navigate quickly between two classes: class A's in-flight fetch resolves after class B's → setDecks(A-data) renders wrong-class content until the next action. Module-level cache never invalidated (brief stale flash on revisits).", "expected": "Only the current class's data applies.", "actual": "Last-resolved-wins with no guard.", "impact": "Wrong-class content displayed transiently.", "evidence": "No abort/stale-response guard (verified).", "testCoverage": "None.", "fixDirection": "AbortController or sequence check per classSlug; invalidate/drop the module cache.", "fixComplexity": "S-M", "regressionTest": "Out-of-order resolution of two fetchAll calls → only latest class's data set.", "needsRuntimeConfirmation": true }, { "id": "FIN-47", "sourceIds": ["ADV-03"], "title": "Logout has no error handling — failed logout strands the user with an unhandled rejection", "severity": "Low", "confidence": "Confirmed", "files": ["src/components/ui/Navbar.tsx:55-59"], "functions": ["handleLogout"], "scenario": "Network failure during POST /api/auth/logout → router.push('/login') never runs; unhandled rejection; the user appears logged out in UI but the session cookie persists.", "expected": "Graceful failure handling.", "actual": "None.", "impact": "Stranded session on transient errors.", "evidence": "Navbar.tsx:55-58 (verified).", "testCoverage": "None.", "fixDirection": "try/catch + user feedback; force navigation regardless.", "fixComplexity": "S", "regressionTest": "Mock failed logout → navigation still occurs or error shown.", "needsRuntimeConfirmation": false }, { "id": "FIN-48", "sourceIds": ["ADV-05"], "title": "slugify can yield an empty slug (unreachable class) and renames never update the URL slug", "severity": "Low", "confidence": "Confirmed", "files": ["src/services/classService.ts:4-10,47-64,66-71"], "functions": ["slugify", "createClass", "updateClass"], "scenario": "Create a class named '!!!' → slug '' → class created but unreachable via /[classSlug] (empty segment). Rename a class → slug stays the old value; bookmarked/old URLs keep pointing at the old slug while the header shows the new name.", "expected": "Always-nonempty unique slugs; rename updates the slug (or redirects).", "actual": "Empty slug possible; rename never touches slug.", "impact": "Unreachable content; stale URLs after rename.", "evidence": "classService.ts:4-9 slugify drops all non-alphanumerics (verified); updateClass updates name only.", "testCoverage": "None.", "fixDirection": "Fallback slug (e.g. 'class' + counter) when slugify returns ''; decide rename-vs-slug policy (update slug or 301 old → new).", "fixComplexity": "S", "regressionTest": "createClass('!!!') → non-empty unique slug; rename → old slug resolves or redirects.", "needsRuntimeConfirmation": true }, { "id": "FIN-49", "sourceIds": ["ADV-08"], "title": "Shared-viewer localStorage session keys are item-scoped, not token-scoped — progress collides across share tokens and with the owner's own sessions", "severity": "Low", "confidence": "High-confidence inference", "files": ["src/app/shared/[classSlug]/[type]/[token]/SharedViewer.tsx:26-33", "src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx:24-41", "src/components/flashcards/FlashcardViewer.tsx:129-150"], "functions": ["localStorage session save/load"], "scenario": "Two share links for the same deck/quiz (or the owner studying locally) share one localStorage key (flashcard_progress_ / quiz_progress_): progress from one token's session restores into another's; restored orders can reference cards not in this payload → stuck blank viewer (recoverable via restart).", "expected": "Session scoped to the share token.", "actual": "Scoped to content id only.", "impact": "Cross-token session bleed; stuck viewers in edge cases.", "evidence": "Key construction in SharedViewer/SharedGroupViewer (verified).", "testCoverage": "None.", "fixDirection": "Include the token (or a per-viewer random id) in the localStorage key.", "fixComplexity": "S", "regressionTest": "Two tokens for the same content → independent sessions.", "needsRuntimeConfirmation": true }, { "id": "FIN-50", "sourceIds": ["GRP-07"], "title": "Group sortOrder uses an inverted desc convention (newest-first) with an unvalidated PATCH — latent trap for future group reordering", "severity": "Observation", "confidence": "Confirmed", "files": ["src/app/api/material-groups/route.ts:26,43-47", "src/app/api/material-groups/[id]/route.ts:11-21"], "functions": ["GET/POST /api/material-groups", "PATCH /api/material-groups/[id]"], "scenario": "Groups order by sortOrder desc (newest first) while every other sortOrder in the codebase is asc; PATCH accepts arbitrary/duplicate sortOrder values with no validation. Internally consistent today (no group drag UI); the convention is fragile and the PATCH is a footgun.", "expected": "Consistent, validated semantics.", "actual": "Inverted convention, unbounded PATCH values.", "impact": "Latent ordering trap; self-harm only via crafted requests.", "evidence": "material-groups/route.ts:26 (verified).", "testCoverage": "None.", "fixDirection": "Remove sortOrder from the group PATCH contract or validate/normalize it; document the convention.", "fixComplexity": "S", "regressionTest": "PATCH with sortOrder 'abc' → 400.", "needsRuntimeConfirmation": false }, { "id": "FIN-51", "sourceIds": ["FE-11"], "title": "Collapsed-groups state read in an effect — brief expand flash on every library visit", "severity": "Observation", "confidence": "Confirmed", "files": ["src/app/(protected)/[classSlug]/flashcards/page.tsx:190-195", "src/app/(protected)/[classSlug]/quizzes/page.tsx:177-182"], "functions": ["collapsed-groups localStorage init"], "scenario": "Collapsed groups render expanded for one frame after navigation, then collapse (effect runs post-paint). Cosmetic; source of a known lint warning.", "expected": "Collapsed from first paint.", "actual": "One-frame flash.", "impact": "Cosmetic layout jump.", "evidence": "useState {} + effect sync (verified).", "testCoverage": "None.", "fixDirection": "Lazy useState initializer or useSyncExternalStore.", "fixComplexity": "S", "regressionTest": "Render with saved collapsed state → collapsed on first paint.", "needsRuntimeConfirmation": true }, { "id": "FIN-52", "sourceIds": ["AUTH-10"], "title": "Shared quiz links ship the full answer key (isCorrect, rationale) to anonymous viewers — by design, but worth an explicit warning", "severity": "Observation", "confidence": "Confirmed", "files": ["src/services/shareService.ts:13-23", "src/components/quizzes/QuizViewer.tsx:377-487", "src/components/ui/ShareMenu.tsx:130"], "functions": ["getShareLink", "QuizViewer shared grading"], "scenario": "Anyone with a shared quiz token can read correct answers and rationales from the payload — inherent to the client-side self-grading design and documented ('Anyone with the link can view and study'). The token IS the answer key.", "expected": "Documented sharing contract.", "actual": "Exactly that.", "impact": "None beyond the documented contract; consider a confirmation in the Share menu.", "evidence": "shareService include without select-sanitization (verified).", "testCoverage": "None.", "fixDirection": "Optional: warning text in ShareMenu; a future 'practice-only' share mode without isCorrect.", "fixComplexity": "S", "regressionTest": "Assert shared payload omits attempts/progress/answersJson.", "needsRuntimeConfirmation": false }, { "id": "FIN-53", "sourceIds": ["TEST-01"], "title": "False-confidence test: 'distinct valid state for every rating' never asserts distinctness — scheduleRating rating-dependence is untested", "severity": "Low", "confidence": "Confirmed", "files": ["src/lib/spacedRepetition.test.ts:15-23"], "functions": ["scheduleRating (test)"], "scenario": "The loop over AGAIN/HARD/GOOD/EASY asserts only reps===1, lastReview===now, due>now per rating — no cross-rating comparison. If ratingMap broke (all ratings → Good), the test still passes. previewRatings (a different function) is tested separately; scheduleRating's rating dependence is never asserted for learning/review states.", "expected": "Ratings produce distinct schedules.", "actual": "Distinctness never asserted.", "impact": "False confidence in the SRS scheduling core.", "evidence": "spacedRepetition.test.ts:15-23 (verified).", "testCoverage": "The finding.", "fixDirection": "Assert pairwise due ordering AGAIN < HARD < GOOD < EASY and distinct due timestamps; add a graduated-state lapse case.", "fixComplexity": "S", "regressionTest": "The improved test itself.", "needsRuntimeConfirmation": false } ], "notes": "Severity: Critical = destructive data loss / credential exposure / unauthorized access / unrecoverable migration failure / app-wide failure; High = major feature failure / persistent state corruption / deployment failure; Medium = meaningful incorrect behavior; Low = limited concrete defect; Observation = useful concern without confirmed defect. Confidence: Confirmed / High-confidence inference / Unverified risk. FIN-01..FIN-53 are the consolidated, deduplicated findings; source IDs from specialist auditors are preserved in sourceIds. FE-06 ('no auth on API routes') was refuted by the adversarial review and is recorded in REJECTED_FINDINGS.md. Test-gap findings beyond FIN-53 (scoring/auth/SRS-service/attempt/jsonRepair/shuffle/rateLimiter missing tests, no integration harness) are detailed in TEST_GAPS.md." }