From c3c26718f93a4d0a3e89f792d6340bd855c92933 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 31 Jul 2026 10:46:36 -0700 Subject: [PATCH 1/8] Delete task.md --- task.md | 31 ------------------------------- 1 file changed, 31 deletions(-) delete mode 100644 task.md diff --git a/task.md b/task.md deleted file mode 100644 index ede706a..0000000 --- a/task.md +++ /dev/null @@ -1,31 +0,0 @@ -# Study App — Task Tracker - -## Phase 1 & 2: Scaffold, Auth, Classes, Flashcards (COMPLETED) -- [x] Scaffold Next.js project with TypeScript + Tailwind -- [x] Setup Prisma schema + migrations -- [x] Create auth system and middleware -- [x] Create layout, design system (globals.css), login page -- [x] Create Class list and Flashcard list pages -- [x] Create Deck/Card services, import validation, and jsonRepair -- [x] Create Import Modal (Generate/Import tabs) -- [x] Create FlashcardViewer (swipe, flip, running tally, self-grading) -- [x] Create CardManager (edit, delete, add cards) - -## Phase 4 & 5: Quiz Taking, Scoring, Attempts (COMPLETED) -- [x] Implement Quiz taking UI (`src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx`) -- [x] Implement Multiple-choice and SATA question viewers -- [x] Implement submit-gated running score -- [x] Implement quiz submission logic (create `QuizAttempt`) -- [x] Implement Results screen (non-full-credit review list with rationale) -- [x] Implement Category Breakdown logic and UI -- [x] Implement "Retake missed only" flow -- [x] Implement Quiz Attempt History view - -## Phase 6: Public Sharing (COMPLETED) -- [x] Create `ShareLink` service and API routes -- [x] Implement share toggle UI on decks and quizzes -- [x] Create public shared pages (`/shared/...`) that bypass auth - -## Phase 7: Polish (COMPLETED) -- [x] Ensure mobile responsiveness across Flashcard and Quiz study views -- [x] Final manual testing and walkthrough From faaccf8a7e87444d70cea712053e31ef2761ff2f Mon Sep 17 00:00:00 2001 From: Elijah Date: Sat, 1 Aug 2026 08:48:26 -0700 Subject: [PATCH 2/8] Changed: Show newly created material groups first and place uncategor - --- src/app/(protected)/[classSlug]/flashcards/page.tsx | 2 +- src/app/(protected)/[classSlug]/quizzes/page.tsx | 2 +- src/app/api/material-groups/route.ts | 2 +- src/components/import/ImportTab.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/(protected)/[classSlug]/flashcards/page.tsx b/src/app/(protected)/[classSlug]/flashcards/page.tsx index 8a455f1..5ff4316 100644 --- a/src/app/(protected)/[classSlug]/flashcards/page.tsx +++ b/src/app/(protected)/[classSlug]/flashcards/page.tsx @@ -250,7 +250,7 @@ export default function FlashcardsPage() { }); if (res.ok) { const g = await res.json(); - setGroups((prev) => [...prev, g]); + setGroups((prev) => [g, ...prev]); setIsCreatingGroup(false); setNewGroupName(""); } diff --git a/src/app/(protected)/[classSlug]/quizzes/page.tsx b/src/app/(protected)/[classSlug]/quizzes/page.tsx index 97be050..0509c3b 100644 --- a/src/app/(protected)/[classSlug]/quizzes/page.tsx +++ b/src/app/(protected)/[classSlug]/quizzes/page.tsx @@ -237,7 +237,7 @@ export default function QuizzesPage() { }); if (res.ok) { const g = await res.json(); - setGroups((prev) => [...prev, g]); + setGroups((prev) => [g, ...prev]); setIsCreatingGroup(false); setNewGroupName(""); } diff --git a/src/app/api/material-groups/route.ts b/src/app/api/material-groups/route.ts index 6184603..2485c6d 100644 --- a/src/app/api/material-groups/route.ts +++ b/src/app/api/material-groups/route.ts @@ -23,7 +23,7 @@ export async function GET(request: NextRequest) { try { const groups = await prisma.materialGroup.findMany({ where: whereClause, - orderBy: { sortOrder: "asc" }, + orderBy: [{ sortOrder: "desc" }, { createdAt: "desc" }], }); return NextResponse.json(groups); } catch (error) { diff --git a/src/components/import/ImportTab.tsx b/src/components/import/ImportTab.tsx index 508fdc3..13c3b56 100644 --- a/src/components/import/ImportTab.tsx +++ b/src/components/import/ImportTab.tsx @@ -170,12 +170,12 @@ export function ImportTab({ classId, importType, onImported }: ImportTabProps) { onChange={(e) => setSelectedGroupId(e.target.value)} className="w-full px-3.5 py-2.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200" > - {materialGroups.map((group) => ( ))} + From 089439ed90dc602ddac27de9213078bb2e22fdc5 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 7 Aug 2026 19:31:23 -0700 Subject: [PATCH 3/8] Refactor Study Desk application structure --- .dockerignore | 20 + .forgejo/workflows/build.yml | 45 +- .gitattributes | 2 + .gitignore | 6 + Dockerfile | 23 +- README.md | 75 +- audit-results/CONFIRMED_FINDINGS.md | 67 + audit-results/COVERAGE_MAP.md | 38 + audit-results/EXECUTIVE_SUMMARY.md | 69 + audit-results/FINDINGS.json | 1024 +++++++++ audit-results/REJECTED_FINDINGS.md | 15 + audit-results/REMEDIATION_PLAN.md | 340 +++ audit-results/SUBAGENT_REPORTS.md | 20 + audit-results/TEST_GAPS.md | 21 + audit-results/UNVERIFIED_RISKS.md | 35 + audit-results/VERIFICATION_LOG.md | 38 + audit-results/tmp/audit-migration.db | Bin 0 -> 221184 bytes audit-results/tmp/generated-before.sha256 | 26 + audit-results/tmp/inspect-db.cjs | 52 + audit-results/tmp/reproduce-drift.cjs | 40 + audit-results/tmp/validate-findings.cjs | 27 + benchmarks/remediationBenchmark.test.ts | 149 ++ ...ose.override.yml => docker-compose.dev.yml | 11 +- docker-compose.yml | 8 +- docker-entrypoint.sh | 10 +- eslint.config.mjs | 1 + out.css | 1 - package-lock.json | 625 ++--- package.json | 11 +- .../migration.sql | 68 + .../migration.sql | 2 + .../migration.sql | 3 + prisma/schema.prisma | 17 +- scripts/backup-database.mjs | 42 + scripts/databasePath.mjs | 10 + scripts/migration-preflight.mjs | 312 +++ scripts/request-password-reset.mjs | 56 + scripts/verify-backup-restore.mjs | 197 ++ scripts/verify-http-smoke.mjs | 83 + .../[classSlug]/flashcards/[deckId]/page.tsx | 36 +- .../[classSlug]/flashcards/page.tsx | 313 ++- .../[classSlug]/quizzes/[quizId]/page.tsx | 31 +- .../(protected)/[classSlug]/quizzes/page.tsx | 303 ++- src/app/(protected)/page.tsx | 51 +- src/app/api/auth/login/route.ts | 3 +- .../api/auth/password-reset/complete/route.ts | 12 + .../api/auth/password-reset/request/route.ts | 27 +- .../api/auth/password-reset/verify/route.ts | 3 +- src/app/api/auth/setup-status/route.ts | 8 +- src/app/api/cards/[id]/route.ts | 30 +- src/app/api/classes/[id]/route.ts | 31 +- src/app/api/classes/route.ts | 25 +- src/app/api/decks/[id]/cards/route.ts | 29 +- src/app/api/decks/[id]/route.ts | 28 +- src/app/api/decks/reorder/route.ts | 41 +- src/app/api/decks/route.ts | 49 +- src/app/api/health/route.ts | 12 + src/app/api/material-groups/[id]/route.ts | 54 +- src/app/api/material-groups/route.ts | 77 +- src/app/api/progress/route.ts | 92 +- src/app/api/quizzes/[id]/attempt/route.ts | 73 +- src/app/api/quizzes/[id]/route.ts | 28 +- src/app/api/quizzes/reorder/route.ts | 41 +- src/app/api/quizzes/route.ts | 52 +- src/app/api/share/route.ts | 56 +- src/app/login/page.tsx | 39 +- .../[type]/[token]/SharedGroupViewer.tsx | 47 +- .../[type]/[token]/SharedViewer.tsx | 23 +- .../[classSlug]/[type]/[token]/page.tsx | 22 +- src/components/flashcards/CardManager.tsx | 5 +- src/components/flashcards/FlashcardViewer.tsx | 236 +- src/components/import/CreateTab.tsx | 14 +- src/components/import/GenerateTab.tsx | 57 +- src/components/import/ImportTab.tsx | 3 + src/components/quizzes/QuizResults.tsx | 28 +- src/components/quizzes/QuizViewer.tsx | 274 ++- .../SpacedRepetitionSets.tsx | 11 +- src/components/ui/Navbar.tsx | 20 +- src/components/ui/ShareMenu.tsx | 81 +- src/generated/prisma/internal/class.ts | 16 +- .../prisma/internal/prismaNamespace.ts | 93 +- .../prisma/internal/prismaNamespaceBrowser.ts | 3 + src/generated/prisma/models/QuizAttempt.ts | 37 +- src/generated/prisma/models/StudyProgress.ts | 90 +- src/lib/auth.ts | 32 +- src/lib/limitedJson.test.ts | 20 + src/lib/limitedJson.ts | 24 + src/lib/progressNormalization.test.ts | 70 + src/lib/progressNormalization.ts | 108 + src/lib/publicPaths.test.ts | 25 + src/lib/publicPaths.ts | 17 + src/lib/quizSnapshots.ts | 33 + src/lib/rateLimiter.test.ts | 30 + src/lib/rateLimiter.ts | 4 + src/lib/scoring.test.ts | 48 + src/lib/scoring.ts | 9 +- src/lib/sessionConfig.test.ts | 43 + src/lib/sessionConfig.ts | 58 + src/lib/shareMetadata.test.ts | 34 + src/lib/shareMetadata.ts | 33 +- src/lib/spacedRepetition.test.ts | 16 + src/lib/validation/attemptSchemas.test.ts | 35 + src/lib/validation/attemptSchemas.ts | 23 + src/lib/validation/contentSchemas.ts | 43 + src/lib/validation/importSchemas.test.ts | 84 + src/lib/validation/importSchemas.ts | 77 +- src/lib/validation/materialGroupSchemas.ts | 19 + src/lib/validation/progressSchemas.test.ts | 50 + src/lib/validation/progressSchemas.ts | 64 + src/lib/validation/reorderSchemas.ts | 27 +- src/lib/validation/shareSchemas.test.ts | 12 + src/lib/validation/shareSchemas.ts | 8 + src/proxy.test.ts | 30 + src/proxy.ts | 37 +- src/services/activityService.test.ts | 32 + src/services/activityService.ts | 69 +- src/services/authService.test.ts | 48 + src/services/authService.ts | 75 +- src/services/classService.test.ts | 27 + src/services/classService.ts | 3 +- src/services/deckService.ts | 31 +- src/services/healthService.ts | 8 + src/services/materialGroupService.test.ts | 40 + src/services/materialGroupService.ts | 66 + src/services/progressService.test.ts | 131 ++ src/services/progressService.ts | 214 +- src/services/quizService.test.ts | 150 ++ src/services/quizService.ts | 151 +- src/services/reorderService.test.ts | 91 + src/services/reorderService.ts | 101 + src/services/shareService.test.ts | 61 + src/services/shareService.ts | 123 +- src/services/spacedRepetitionService.test.ts | 154 ++ src/services/spacedRepetitionService.ts | 50 +- src/types/study.ts | 2 +- temp.css | 2017 ----------------- test.css | 1 - tests/helpers/testDatabase.test.ts | 41 + tests/helpers/testDatabase.ts | 107 + tests/migrationPreflight.test.ts | 151 ++ tests/migrationSchema.test.ts | 40 + tests/prismaMigrationDeploy.test.ts | 174 ++ tests/setup/globalSetup.ts | 15 + vitest.benchmark.config.ts | 18 + vitest.config.ts | 2 + 145 files changed, 8087 insertions(+), 3412 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 audit-results/CONFIRMED_FINDINGS.md create mode 100644 audit-results/COVERAGE_MAP.md create mode 100644 audit-results/EXECUTIVE_SUMMARY.md create mode 100644 audit-results/FINDINGS.json create mode 100644 audit-results/REJECTED_FINDINGS.md create mode 100644 audit-results/REMEDIATION_PLAN.md create mode 100644 audit-results/SUBAGENT_REPORTS.md create mode 100644 audit-results/TEST_GAPS.md create mode 100644 audit-results/UNVERIFIED_RISKS.md create mode 100644 audit-results/VERIFICATION_LOG.md create mode 100644 audit-results/tmp/audit-migration.db create mode 100644 audit-results/tmp/generated-before.sha256 create mode 100644 audit-results/tmp/inspect-db.cjs create mode 100644 audit-results/tmp/reproduce-drift.cjs create mode 100644 audit-results/tmp/validate-findings.cjs create mode 100644 benchmarks/remediationBenchmark.test.ts rename docker-compose.override.yml => docker-compose.dev.yml (70%) delete mode 100644 out.css create mode 100644 prisma/migrations/20260807090000_add_material_groups/migration.sql create mode 100644 prisma/migrations/20260807091000_add_quiz_attempt_snapshot/migration.sql create mode 100644 prisma/migrations/20260807092000_add_progress_revisions/migration.sql create mode 100644 scripts/backup-database.mjs create mode 100644 scripts/databasePath.mjs create mode 100644 scripts/migration-preflight.mjs create mode 100644 scripts/request-password-reset.mjs create mode 100644 scripts/verify-backup-restore.mjs create mode 100644 scripts/verify-http-smoke.mjs create mode 100644 src/app/api/health/route.ts create mode 100644 src/lib/limitedJson.test.ts create mode 100644 src/lib/limitedJson.ts create mode 100644 src/lib/progressNormalization.test.ts create mode 100644 src/lib/progressNormalization.ts create mode 100644 src/lib/publicPaths.test.ts create mode 100644 src/lib/publicPaths.ts create mode 100644 src/lib/quizSnapshots.ts create mode 100644 src/lib/rateLimiter.test.ts create mode 100644 src/lib/scoring.test.ts create mode 100644 src/lib/sessionConfig.test.ts create mode 100644 src/lib/sessionConfig.ts create mode 100644 src/lib/shareMetadata.test.ts create mode 100644 src/lib/validation/attemptSchemas.test.ts create mode 100644 src/lib/validation/attemptSchemas.ts create mode 100644 src/lib/validation/contentSchemas.ts create mode 100644 src/lib/validation/importSchemas.test.ts create mode 100644 src/lib/validation/materialGroupSchemas.ts create mode 100644 src/lib/validation/progressSchemas.test.ts create mode 100644 src/lib/validation/progressSchemas.ts create mode 100644 src/lib/validation/shareSchemas.test.ts create mode 100644 src/lib/validation/shareSchemas.ts create mode 100644 src/proxy.test.ts create mode 100644 src/services/activityService.test.ts create mode 100644 src/services/authService.test.ts create mode 100644 src/services/classService.test.ts create mode 100644 src/services/healthService.ts create mode 100644 src/services/materialGroupService.test.ts create mode 100644 src/services/materialGroupService.ts create mode 100644 src/services/progressService.test.ts create mode 100644 src/services/quizService.test.ts create mode 100644 src/services/reorderService.test.ts create mode 100644 src/services/reorderService.ts create mode 100644 src/services/shareService.test.ts create mode 100644 src/services/spacedRepetitionService.test.ts delete mode 100644 temp.css delete mode 100644 test.css create mode 100644 tests/helpers/testDatabase.test.ts create mode 100644 tests/helpers/testDatabase.ts create mode 100644 tests/migrationPreflight.test.ts create mode 100644 tests/migrationSchema.test.ts create mode 100644 tests/prismaMigrationDeploy.test.ts create mode 100644 tests/setup/globalSetup.ts create mode 100644 vitest.benchmark.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..088cd7e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +node_modules +.next +.git +.forgejo +.github +.env* +!.env.example +dev.db* +study.db* +data +coverage +.test-databases +.npm-cache +.prisma-cache +audit-results +*.log +*.tsbuildinfo +out.css +temp.css +test.css diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 4ed4ca5..7abe151 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -1,4 +1,4 @@ -name: Automated Container Build +name: Verify and publish container on: push: @@ -16,10 +16,41 @@ jobs: run: | echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin - - name: Build and Push Image + - name: Install locked dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Validate Prisma schema and migration drift run: | - # Force the entire image path string to lowercase dynamically - IMAGE_PATH=$(echo "git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}:latest" | tr '[:upper:]' '[:lower:]') - - docker build -t "$IMAGE_PATH" . - docker push "$IMAGE_PATH" \ No newline at end of file + npx prisma validate + DATABASE_URL=file:./ci-migration.test.db npx prisma migrate deploy + npx prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --exit-code + + - name: Build application + run: npm run build + + - name: Lint changed source files + run: | + FILES=$(git diff --name-only HEAD^ HEAD -- '*.ts' '*.tsx' '*.js' '*.mjs') + if [ -n "$FILES" ]; then npx eslint $FILES; fi + + - name: Build and smoke production image + run: | + IMAGE_PATH=$(echo "git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]') + IMAGE_SHA="$IMAGE_PATH:${{ github.sha }}" + CI_SECRET=$(openssl rand -hex 32) + docker build -t "$IMAGE_SHA" . + docker run -d --name study-smoke -p 3000:3726 -e SESSION_SECRET="$CI_SECRET" -e ALLOW_INITIAL_SETUP=true "$IMAGE_SHA" + for attempt in $(seq 1 30); do + if [ "$(docker inspect --format='{{.State.Health.Status}}' study-smoke)" = "healthy" ]; then break; fi + sleep 2 + done + test "$(docker inspect --format='{{.State.Health.Status}}' study-smoke)" = "healthy" + curl --fail http://127.0.0.1:3000/login > /dev/null + curl --fail http://127.0.0.1:3000/api/auth/setup-status | grep '"setupRequired":true' + docker rm -f study-smoke + docker tag "$IMAGE_SHA" "$IMAGE_PATH:latest" + docker push "$IMAGE_SHA" + docker push "$IMAGE_PATH:latest" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f1f1a79 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Prisma 7 generated doc comments contain trailing spaces; do not hand-edit generated output. +src/generated/prisma/** -whitespace diff --git a/.gitignore b/.gitignore index 03f032b..4da595d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,12 +12,18 @@ # testing /coverage +/.test-databases/ # local SQLite study data /dev.db /dev.db-journal /dev.db-shm /dev.db-wal +/data/ +/study.db +/study.db-journal +/study.db-shm +/study.db-wal # next.js /.next/ diff --git a/Dockerfile b/Dockerfile index 1c5addf..90c938b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,19 @@ +# ---- base ---- +FROM node:22-slim AS base +RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* + # ---- deps ---- -FROM node:22-slim AS deps +FROM base AS deps WORKDIR /app COPY package.json package-lock.json ./ -RUN npm install +RUN npm ci + +# ---- production dependencies ---- +FROM deps AS runtime-deps +RUN npm prune --omit=dev # ---- builder ---- -FROM node:22-slim AS builder +FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . @@ -13,13 +21,13 @@ RUN npx prisma generate RUN npm run build # ---- runner ---- -FROM node:22-slim AS runner +FROM base AS runner WORKDIR /app ENV NODE_ENV=production ENV PORT=3726 +ENV HOSTNAME=0.0.0.0 ENV DATABASE_URL="file:/app/data/study.db" -RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* RUN useradd --system --create-home appuser && mkdir -p /app/data && chown -R appuser:appuser /app COPY --from=builder /app/.next/standalone ./ @@ -27,10 +35,13 @@ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/public ./public COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma.config.ts ./ -RUN npm install prisma@^7.8.0 +COPY --from=builder /app/scripts ./scripts +COPY --from=runtime-deps /app/node_modules ./node_modules COPY docker-entrypoint.sh ./ RUN chmod +x docker-entrypoint.sh # Running as root to avoid permission denied on Unraid host-mounted volumes EXPOSE 3726 VOLUME ["/app/data"] +HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=6 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:3726/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/README.md b/README.md index e215bc4..17eac58 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,77 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +Study Desk is a self-hosted, single-user study application. + +## Local development + +```powershell +npm.cmd ci +npx.cmd prisma generate +npm.cmd run dev +``` + +The automated test harness always creates a uniquely named database under +`.test-databases/`, applies committed migrations, and removes the database after +the run. It refuses `dev.db`, `/app/data/study.db`, existing databases, and paths +that are not explicitly test-named. + +## Production container + +Set a unique session secret of at least 32 characters. Production startup fails +before migrations or the HTTP server when the secret is missing, short, or the +published development fallback. + +```powershell +$env:SESSION_SECRET = '' +docker compose -f docker-compose.yml up --build +``` + +The host listens on `http://localhost:3000`; the container listens on port 3726. +Development Compose is intentionally explicit and is never auto-merged: + +```powershell +docker compose -f docker-compose.dev.yml up --build +``` + +For first-time production setup, either provide an Argon2 encoded +`ADMIN_PASSWORD_HASH`, or temporarily set `ALLOW_INITIAL_SETUP=true` for the +one-time setup request. Remove the flag after setup. Production HTTP password +reset initiation is unavailable; run this on the server instead: + +```powershell +npm.cmd run auth:reset +``` + +If TLS terminates at a trusted reverse proxy, forward requests only from that +proxy and set `SECURE_COOKIES=true`. Leave it false for intentional plain HTTP; +Secure cookies cannot be used over plain HTTP. + +## Database backup, migration adoption, and restore + +Never run migration tests against the only copy of a study database. Create a +lock-safe SQLite backup through the backup API and verify it before deployment: + +```powershell +$env:DATABASE_URL = 'file:./data/study.db' +npm.cmd run db:backup -- --output ./backups/study-before-upgrade.db +npm.cmd run db:preflight +npx.cmd prisma migrate deploy +``` + +The material-group preflight reports one of four states: + +- `APPLY`: migration history is complete and the group schema is absent; run + `prisma migrate deploy` normally. +- `ADOPT`: prior migrations are tracked and the database exactly matches the + intended group schema. After reviewing the verified backup, explicitly run + `npm run db:preflight -- --resolve --backup `. +- `CURRENT`: migration and schema already agree. +- `CONFLICT`: partial or unknown state. Stop and recover manually; do not use + `db push`, reset, edit applied migrations, or mark anything applied. + +Restore drill: stop Study Desk, keep the damaged database as evidence, restore +the verified backup to a new path, run SQLite `integrity_check` plus +`npm run db:preflight`, start the same prior application image against the +restored path, and compare class/deck/quiz counts plus representative content. +Only swap the production path after those checks pass. ## Getting Started diff --git a/audit-results/CONFIRMED_FINDINGS.md b/audit-results/CONFIRMED_FINDINGS.md new file mode 100644 index 0000000..4e08f96 --- /dev/null +++ b/audit-results/CONFIRMED_FINDINGS.md @@ -0,0 +1,67 @@ +# Confirmed Findings + +Severity: Critical / High / Medium / Low / Observation. Confidence: Confirmed / High-confidence inference / Unverified risk. +Full structured detail for every finding (files, functions, scenario, expected/actual, root cause, impact, evidence, fix direction, complexity, regression test, runtime-confirmation flag) is in `FINDINGS.json` (validated). Source IDs from specialist auditors are in parentheses. + +| ID | Severity | Confidence | Title | Area | +|---|---|---|---|---| +| FIN-01 (DBAUD-01, GRP-01) | **Critical** | Confirmed | Schema/migration drift: `MaterialGroup` table + `groupId` columns never migrated — fresh deployments' databases are incompatible with the app (verified: `no such column: Deck.groupId`, `no such table: MaterialGroup`) | DB/migrations | +| FIN-02 (AUTH-01, OPS-03) | **Critical** | High-confidence inference | Hardcoded fallback `SESSION_SECRET` (`"dev-session-secret-change-in-production-must-be-32-chars"`) in `src/lib/auth.ts:15` + `src/proxy.ts:8-9`; empty env var in shipped compose → forgeable session cookie → full auth bypass | Auth/deploy | +| FIN-03 (QUIZ-01, ADV-01) | **High** | Confirmed | In-viewer "Retake Missed" never sets `retakeIds` prop → persisted as a full attempt with a wrong, lower score; pollutes SEQUENTIAL progress | Quiz | +| FIN-04 (CARD-01, IMPT-01, QUIZ-06) | **High** | Confirmed | Stale card ids in saved progress never filtered on resume (`filterAndClampOrder` dead code) → deleting a card mid-session bricks the flashcard study session | Flashcards | +| FIN-05 (OPS-01) | **High** | Confirmed | Port mismatch: container listens on 3726, compose publishes 3000:3000 → production deployment unreachable | Deploy | +| FIN-06 (OPS-06) | **High** | High-confidence inference | No `.dockerignore`: Windows-host builds inject win32 native modules (better-sqlite3, argon2) into the Linux image | Deploy | +| FIN-07 (OPS-08) | **High** | Confirmed | `docker-compose.override.yml` auto-merges on plain `docker compose up` → production command silently runs dev mode | Deploy | +| FIN-08 (AUTH-04, ADV-09) | Medium | High-confidence inference | First-login provisioning takeover; `ADMIN_PASSWORD_HASH` env is dead config | Auth | +| FIN-09 (AUTH-02) | Medium | High-confidence inference | Proxy auth bypass for any path containing `.` (latent today; would expose any future non-UUID-id route) | Auth | +| FIN-10 (AUTH-03) | Medium | High-confidence inference | Password-reset abuse: token overwrite DoS, spoofable `x-forwarded-for` rate limits, unthrottled `complete` | Auth | +| FIN-11 (QUIZ-02) | Medium | High-confidence inference | Attempt route stores unvalidated `answersJson`; duplicate option ids inflate SATA credit; malformed shapes → 500 | Quiz | +| FIN-12 (QUIZ-03, FE-04) | Medium | Confirmed | Quiz Finish double-submit creates duplicate attempts; failure path silent | Quiz/FE | +| FIN-13 (QUIZ-04) | Medium | High-confidence inference | Progress PATCH can land after Finish's DELETE → stale "Continue" row for a completed quiz | Quiz | +| FIN-14 (CARD-02, FE-05, QUIZ-09) | Medium | High-confidence inference | Fire-and-forget, unsequenced progress autosaves: out-of-order PATCHes regress resume state; multi-tab last-write-wins | Flashcards/Quiz | +| FIN-15 (CARD-03, ADV-07) | Medium | Confirmed | Completed flashcard session never persisted as complete → resume re-shows the last graded card | Flashcards | +| FIN-16 (CARD-04) | Medium | Confirmed | SRS review endpoint enforces membership only — no due-date or `newCardsPerDay` enforcement server-side | SRS | +| FIN-17 (CARD-05, ADV-02) | Medium | Confirmed | Unbounded DB scans: whole `StudyActivity` table per fetch; all SRS card states per set; Navbar polls every 60 s | Perf/DB | +| FIN-18 (FE-01, GRP-03, FE-02, GRP-05) | Medium | Confirmed | Library optimistic mutations are fire-and-forget (no `res.ok`, no rollback); cross-group reorder omits origin group from payload | FE/Groups | +| FIN-19 (GRP-02, CARD-07, AUTH-06) | Medium | Confirmed | Reorder/create endpoints trust client `groupId`/`sortOrder` — no class/type/existence validation; cross-class group membership can leak content into another class's share link | Groups | +| FIN-20 (FE-03) | Medium | Confirmed | Shared quiz session resets and options reshuffle on any parent re-render (unstable inline `quiz` prop) | FE | +| FIN-21 (IMPT-02, FE-08, QUIZ-05, ADV-10) | Medium | Confirmed | `/api/progress` PATCH completely unvalidated; unguarded client `JSON.parse` of persisted progress crashes pages | Quiz/FE | +| FIN-22 (OPS-02) | Medium | High-confidence inference | Entrypoint migrate-deploy chain fragile: crash loop on conflicting DB state; runner prisma CLI install unverified/unpinned | Deploy | +| FIN-23 (OPS-05) | Medium | Confirmed | Non-reproducible installs: `npm install` (not `ci`); unpinned `prisma@^7.8.0` re-install re-resolves the whole tree in the runner | Deploy | +| FIN-24 (OPS-07) | Medium | Confirmed | No healthcheck, no backup, no documented recovery | Deploy | +| FIN-25 (OPS-10, TEST-10) | Medium | Confirmed | CI builds/pushes without tests, lint, prisma validate, or container smoke test — deployment-breaking defects ship green | CI | +| FIN-26 (QUIZ-07) | Low | Confirmed | SATA scoring divides by `correctIds.length` with no zero-guard → NaN | Quiz | +| FIN-27 (QUIZ-08) | Low | Confirmed | Historical review/category breakdown recompute scores from current content, not stored attempt | Quiz | +| FIN-28 (IMPT-03) | Low | Confirmed | Card add/edit endpoints bypass Zod: empty strings storable; CreateTab silently drops incomplete cards | Imports | +| FIN-29 (IMPT-04) | Low | Confirmed | No payload size/string-length/array-length caps on import schemas/routes; whitespace-only names accepted | Imports | +| FIN-30 (IMPT-05) | Low | Confirmed | SATA constraint mismatch: instructions say ≥2 correct, schema enforces ≥1 | Imports | +| FIN-31 (IMPT-06) | Low | Confirmed | Unvalidated `name`/`groupId` override fields on import POST routes → 500; PATCH routes mask failures as 404 | Imports | +| FIN-32 (GRP-04) | Low | Confirmed | Group deletion leaves duplicate sortOrder values in Uncategorized; no unique constraint/tie-break | Groups | +| FIN-33 (GRP-06) | Low | Confirmed | Keyboard users cannot move items between groups (empty `handleDragOver`) | Groups/a11y | +| FIN-34 (CARD-08) | Low | High-confidence inference | Restart deletes progress fire-and-forget; slow DELETE can remove the fresh session's progress row | Flashcards | +| FIN-35 (CARD-09) | Low | Confirmed | Concurrent first review of same new card → P2002 → generic 500 instead of 409 | SRS | +| FIN-36 (CARD-10) | Low | High-confidence inference | "Previous card" during the 350 ms grade animation races the pending timeout | Flashcards | +| FIN-37 (CARD-11) | Low | Confirmed | SRS set page never refreshes on focus → stale membership after deck deletion in another tab | SRS/FE | +| FIN-38 (CARD-06) | Low | Confirmed | Day boundary hardcoded to Arizona (UTC-7): "today"/new-card limit/streak roll over at 07:00 UTC | SRS | +| FIN-39 (AUTH-05) | Low | Confirmed | Proxy `destroy()` cookie-clear lost on redirect; layout check ignores `sessionGeneration` | Auth | +| FIN-40 (AUTH-08) | Low | Confirmed | Session cookie Secure flag off in shipped compose (plain HTTP, no TLS story) | Auth/deploy | +| FIN-41 (DBAUD-02, AUTH-07) | Low | Confirmed | `/api/share` accepts arbitrary `targetType` → repeatable junk all-NULL `ShareLink` rows | Auth/DB | +| FIN-42 (DBAUD-03) | Low | High-confidence inference | `dev.db` committed in git history (8 commits), remains in blobs on the LAN remote | Repo hygiene | +| FIN-43 (OPS-09) | Low | Confirmed | `.gitignore` gaps (`data/`, `study.db*`); scratch files `out.css`/`temp.css`/`test.css` tracked | Repo hygiene | +| FIN-44 (FE-07, ADV-04) | Low | Confirmed | Dashboard `fetchClasses` no `.catch`/`res.ok` → unhandled rejection, misleading empty state (or render crash) | FE | +| FIN-45 (FE-09) | Low | Confirmed | Effect fetches without `.catch` in GenerateTab and ShareMenu | FE | +| FIN-46 (FE-10) | Low | High-confidence inference | Cross-class navigation fetch race renders wrong-class data; module cache never invalidated | FE | +| FIN-47 (ADV-03) | Low | Confirmed | Logout has no error handling — failed logout strands the user | FE | +| FIN-48 (ADV-05) | Low | Confirmed | `slugify` can yield an empty slug (unreachable class); renames never update the URL slug | Classes | +| FIN-49 (ADV-08) | Low | High-confidence inference | Shared-viewer localStorage keys item-scoped, not token-scoped → session bleed across tokens | Sharing/FE | +| FIN-50 (GRP-07) | Observation | Confirmed | Group `sortOrder` uses inverted desc convention with unvalidated PATCH — latent trap | Groups | +| FIN-51 (FE-11) | Observation | Confirmed | Collapsed-groups state read in effect → one-frame expand flash | FE | +| FIN-52 (AUTH-10) | Observation | Confirmed | Shared quiz links ship the full answer key to anonymous viewers — by design, worth a warning | Sharing | +| FIN-53 (TEST-01) | Low | Confirmed | False-confidence test: "distinct valid state for every rating" never asserts distinctness | Tests | + +## Notes on consolidation + +- Duplicate findings across specialist reports were merged under one root cause (see `sourceIds`). +- FE-06 ("all /api/* routes unauthenticated") was **refuted** by the adversarial review — `src/proxy.ts` IS the compiled middleware and enforces session checks; the residual truth is FIN-09 (dot-bypass). See REJECTED_FINDINGS.md. +- OPS-02's fresh-volume crash-loop framing was weakened by the adversarial review (on a fresh volume `migrate deploy` succeeds; the app then fails per FIN-01) — reframed as FIN-22. +- FIN-18 severity downgraded from the frontend auditor's High to Medium after adversarial review (no visible break for a single user; silent divergence self-heals on reload). diff --git a/audit-results/COVERAGE_MAP.md b/audit-results/COVERAGE_MAP.md new file mode 100644 index 0000000..467bbc0 --- /dev/null +++ b/audit-results/COVERAGE_MAP.md @@ -0,0 +1,38 @@ +# Coverage Map + +Mapping of repository areas → audit status → specialist report reference. Statuses: done / done+verified (parent-verified with runtime evidence) / partial. + +| Area | Files | Auditor | Status | Report ref | +|---|---|---|---|---| +| Prisma schema/migrations/transactions/cascades | prisma/schema.prisma, prisma/migrations/*, prisma.config.ts, src/lib/db.ts, services | DB specialist (task-1) | done+verified (drift reproduced) | sa_20260806_054030_000000000_5ed877563a0d | +| Quiz scoring/attempts/history/retakes/progress | src/lib/scoring.ts, quizService, progressService, quizzes/*, api/quizzes/**, api/progress | Quiz specialist (task-2) | done+verified (server scoring path re-checked) | sa_20260806_054030_000000000_e75a8ce575f9 | +| Flashcards/SRS/progress/deletion/ordering | flashcards/*, spaced-repetition/*, cardService, deckService, spacedRepetitionService, api/cards|decks|spaced-repetition-sets|progress | Flashcard specialist (task-3) | done | sa_20260806_054030_000000000_a222804c133c | +| Auth/protected routes/API authz/sharing | src/proxy.ts, lib/auth.ts, authService, shareService, shareMetadata, api/auth/**, api/share, shared/*, all api routes | Auth specialist (task-4) | done+verified (proxy + fallback secret read directly) | sa_20260806_054030_000000000_aab0c59dbad3 | +| Imports/exports/generation/Zod/malformed input | lib/validation/*, jsonRepair, components/import/*, api/decks|quizzes|material-groups|settings | Import specialist (task-5) | done | sa_20260806_054030_000000000_5e72c2713989 | +| Material groups/library/drag-drop/deletion/orphans | api/material-groups/**, reorder routes, library pages, classService | Groups specialist (task-6) | done | sa_20260806_054030_000000000_9ae1f3dfa282 | +| Frontend state/refresh/races/localStorage/boundaries | all components + pages + shared viewers | Frontend specialist (task-7) | done | sa_20260806_054030_000000000_0e17fd155702 | +| Test quality/coverage gaps | *.test.ts, vitest.config.ts, package.json, CI workflow | Test specialist (task-8) | done | sa_20260806_054030_000000000_cd3652deb3f7 | +| Docker/startup/env/scripts/production/recovery | Dockerfile, docker-entrypoint.sh, compose files, package.json, prisma.config.ts, next.config.ts, .gitignore, CI | Ops specialist (task-9) | done | sa_20260806_054030_000000000_f49cdaacaabf | +| Adversarial review (challenge Critical/High + missed issues) | (all of the above + lightly-covered files) | Adversarial specialist (task-10) | done — 9/10 challenged findings confirmed/weakened, FE-06 refuted, 10 new issues added | sa_20260806_055608_000000000_c550c742d5dc | + +## Parent-level verification performed (see VERIFICATION_LOG.md and audit-results/tmp/) + +- `npx prisma validate` — PASS +- `npm test` — PASS (30 tests: 20 arcade + 10 spacedRepetition) +- `npm run lint` — 28 baseline problems (9 errors / 19 warnings), all pre-existing +- `npm run build` — PASS (route table incl. "ƒ Proxy (Middleware)") +- `prisma migrate deploy` on fresh temp DB — PASS (creates DB **missing** MaterialGroup/groupId → FIN-01 drift confirmed) +- Temp DB introspection (`audit-results/tmp/inspect-db.cjs`) — `MaterialGroup` table and `groupId` columns absent +- Prisma-shaped SQL against temp DB (`audit-results/tmp/reproduce-drift.cjs`) — `no such column: Deck.groupId`, `no such table: MaterialGroup`, `no such column: ShareLink.groupId` +- `FINDINGS.json` validity — PASS (53 findings; validator `audit-results/tmp/validate-findings.cjs`) + +## Coverage gaps (areas NOT fully audited) + +1. **Arcade feature internals** — intentionally out of scope (may be removed). Only checked for shared-DB/build/deploy impact (Arcade models/indexes verified in migrations). +2. **Real browser interaction** — no browser available in the audit environment: drag-drop, resume flows, share pages, keyboard a11y, theme behavior were code-verified only; findings needing runtime confirmation are flagged in UNVERIFIED_RISKS.md. +3. **Container build/run** — docker CLI unavailable: OPS-02/FIN-22, FIN-06, FIN-24 need a container run to observe exact failure modes. +4. **git history deep-dive** — limited to drift commit (7af0935), dev.db commits, and migration commits; other commits not diffed line-by-line. +5. **Committed dev.db blob contents** — could not be fully enumerated (no sqlite3 CLI; blob grep only; no admin_password hash found in checked blobs). +6. **Live dev.db** — none exists in the workspace; runtime behavior on a populated database (perf findings FIN-17) not measured. +7. **Network/remote behavior** — the Forgejo remote and CI execution were not reachable; CI findings are static analysis of `.forgejo/workflows/build.yml`. +8. **External services** — none (app calls no LLM/third-party APIs). diff --git a/audit-results/EXECUTIVE_SUMMARY.md b/audit-results/EXECUTIVE_SUMMARY.md new file mode 100644 index 0000000..a45f275 --- /dev/null +++ b/audit-results/EXECUTIVE_SUMMARY.md @@ -0,0 +1,69 @@ +# Study Desk — Overnight Read-Only Audit: Executive Summary + +Status: **COMPLETE** (2026-08-06) + +## Scope and method + +Comprehensive read-only audit of the Study repository (Next.js 16 / React 19 / Prisma 7 / SQLite, self-hosted single-user study app). Ten specialist subagents audited: (1) Prisma schema/migrations/transactions/cascades/drift, (2) quiz scoring/attempts/retakes/progress, (3) flashcards/SRS/progress/ordering, (4) auth/protected routes/API authorization/sharing, (5) imports/exports/generation/Zod validation, (6) material groups/library/drag-drop/deletion, (7) frontend state/races/localStorage/boundaries, (8) test quality, (9) Docker/env/production/recovery, (10) adversarial review challenging every Critical/High finding and hunting missed issues. The parent agent independently re-verified all Critical/High claims (code tracing, git history, temp-DB migration chain, DB introspection, Prisma-shaped SQL probes). The Arcade feature set was excluded per scope unless it affects the main app, shared dependencies, database integrity, build, or deployment. + +## Constraints honored + +- **No source code, tests, migrations, config, package files, lockfiles, docs, or databases modified.** Final `git status --short`: only untracked `.reasonix/` and `audit-results/`; `git diff --check` clean; `src/generated/prisma/` byte-identical before/after all prisma commands (sha256). +- All reports and diagnostic artifacts live under `audit-results/` (reports + `tmp/`: `audit-migration.db`, `inspect-db.cjs`, `reproduce-drift.cjs`, `validate-findings.cjs`, `generated-before.sha256`). +- Existing databases: none found in the workspace; the migration-chain test used a brand-new temp DB. + +## Baseline verification + +| Check | Result | +|---|---| +| `npm test` | ✅ PASS — 30 tests / 6 files (20 arcade out of scope, 10 spacedRepetition) | +| `npm run lint` | ⚠️ 28 problems (9 errors, 19 warnings) — all pre-existing baseline | +| `npm run build` | ✅ PASS — full route table incl. "ƒ Proxy (Middleware)" | +| `npx prisma validate` | ✅ PASS | +| Migration chain on fresh temp DB | ✅ PASS — 4/4 migrations apply, but the resulting DB is **missing** `MaterialGroup` + `groupId` columns (drift, see below) | + +## Findings (53 total: 2 Critical, 5 High, 18 Medium, 25 Low, 3 Observation) + +Full detail in `FINDINGS.json` (validated) and `CONFIRMED_FINDINGS.md`. + +### Critical + +1. **FIN-01 — Schema/migration drift (Confirmed, runtime-verified).** `prisma/schema.prisma` defines `MaterialGroup` and `Deck/QuizSet/ShareLink.groupId` (added by commit 7af0935, which also rewrote a committed `dev.db` via `prisma db push`) but **no migration creates them**. Every DB built by `prisma migrate deploy` — the `predev` hook and the Docker entrypoint — lacks these objects; the generated client then fails every Deck/QuizSet/ShareLink/MaterialGroup query (`no such column: Deck.groupId`, `no such table: MaterialGroup` — reproduced against a temp DB). **Any fresh install/deploy is non-functional.** The app only works on the developer's db-push-synced local database. +2. **FIN-02 — Hardcoded session-secret fallback (High-confidence inference).** `src/lib/auth.ts:15` and `src/proxy.ts:8-9` fall back to the public constant `"dev-session-secret-change-in-production-must-be-32-chars"` whenever `SESSION_SECRET` is unset — and the shipped `docker-compose.yml` passes `${SESSION_SECRET}`, which is empty by default. Anyone with the source can forge an iron-session cookie (`{isAuthenticated:true, sessionGeneration:1}`) and fully bypass authentication on such deployments. + +### High + +3. **FIN-03 — "Retake Missed" persists as a full attempt with a wrong lower score** (in-viewer retake never sets the `retakeIds` prop; server scores all unanswered questions as 0) and pollutes SEQUENTIAL progress. +4. **FIN-04 — Deleting a card mid-session bricks the flashcard study session** (`filterAndClampOrder` is dead code; resume never filters stale ids; no skip UI). +5. **FIN-05 — Production compose is unreachable**: container listens on 3726 (`ENV PORT=3726`), compose publishes 3000:3000. +6. **FIN-06 — No `.dockerignore`**: Windows-host builds inject win32 native modules (better-sqlite3, argon2) into the Linux image. +7. **FIN-07 — `docker-compose.override.yml` auto-merges**: plain `docker compose up` silently runs dev mode in production. + +### Medium (selected) + +First-login provisioning takeover + dead `ADMIN_PASSWORD_HASH` (FIN-08); proxy auth bypass for dot-containing paths (FIN-09); password-reset abuse (FIN-10); unvalidated attempt `answersJson` (FIN-11); finish double-submit duplicates attempts (FIN-12); progress PATCH/DELETE race (FIN-13); unsequenced progress autosaves (FIN-14); completed session resumes on last card (FIN-15); SRS review endpoint lacks due/new-card-limit enforcement (FIN-16); unbounded DB scans (FIN-17); library optimistic mutations without rollback (FIN-18); unvalidated `groupId`/`sortOrder` incl. cross-class group membership (FIN-19); shared-quiz reshuffle on re-render (FIN-20); unvalidated `/api/progress` + unguarded `JSON.parse` crashes (FIN-21); entrypoint migrate fragility (FIN-22); non-reproducible installs (FIN-23); no healthcheck/backup (FIN-24); CI ships without tests/lint/prisma/smoke gates (FIN-25). + +### Low / Observation + +25 Low (e.g. SATA div-by-zero NaN, historical score recompute, card-edit endpoints without Zod, no import size caps, sortOrder duplicates after group deletion, keyboard cross-group moves impossible, restart DELETE race, P2002 → 500, git history containing `dev.db`, `.gitignore` gaps, dashboard fetch crash, empty slug from `slugify`, shared-viewer localStorage key collisions) and 3 Observations (group sortOrder desc convention, collapse flash, shared quizzes ship the answer key by design). + +## Verification highlights + +- FIN-01 disproved attempts: checked whether the client avoids selecting `groupId` (it doesn't — generated `Deck.ts` includes it in every payload), whether pages swallow errors (they don't), and whether any migration creates the objects (grep = 0 hits). Confirmed via live probes instead: see `audit-results/tmp/reproduce-drift.cjs` output in VERIFICATION_LOG.md. +- Adversarial review **refuted** the frontend auditor's "no auth on API routes" claim (proxy is compiled middleware — verified in `.next` artifacts), **weakened** the entrypoint crash-loop framing (fresh-volume `migrate deploy` succeeds), and downgraded the optimistic-reorder severity. It independently confirmed all other Critical/High findings and added 10 missed issues, merged into the final list. + +## Repository areas NOT fully audited + +1. Arcade feature internals (out of scope; shared-DB/build/deploy impact only — verified in migrations). +2. Real browser interaction (no browser available): drag-drop, resume flows, share pages, keyboard a11y, themes — code-verified; flagged for runtime confirmation in UNVERIFIED_RISKS.md. +3. Container build/run (docker CLI unavailable): FIN-06, FIN-22, FIN-24 need a container run. +4. Deep git-history diff of every commit; full content audit of the committed `dev.db` blobs (no sqlite3 CLI; no credentials found in the checked blobs). +5. Runtime performance on a populated database (FIN-17 not measured). +6. CI/remote execution (Forgejo pipeline analyzed statically). + +## Recommended priority (when fixes are authorized) + +1. **FIN-01** — generate and commit the missing migration (`prisma migrate dev --name add_material_groups`); add a CI drift gate. Everything else depends on a working fresh deployment. +2. **FIN-02 / FIN-05 / FIN-06 / FIN-07** — harden the deployment path: require `SESSION_SECRET`, fix the port mapping, add `.dockerignore`, rename the dev override, and smoke-test the image in CI. +3. **FIN-03 / FIN-04** — the two most user-visible application bugs (wrong retake scores; stuck flashcard sessions). +4. Then the Medium cluster (validation, races, SRS enforcement, progress handling) and finally Low items + TEST_GAPS.md. diff --git a/audit-results/FINDINGS.json b/audit-results/FINDINGS.json new file mode 100644 index 0000000..437d5eb --- /dev/null +++ b/audit-results/FINDINGS.json @@ -0,0 +1,1024 @@ +{ + "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." +} diff --git a/audit-results/REJECTED_FINDINGS.md b/audit-results/REJECTED_FINDINGS.md new file mode 100644 index 0000000..32d0415 --- /dev/null +++ b/audit-results/REJECTED_FINDINGS.md @@ -0,0 +1,15 @@ +# Rejected Findings + +Findings that were investigated and disproven, or removed as duplicate/stylistic/insignificant, with the reason. + +| ID | Original claim | Rejection reason | +|---|---|---| +| FE-06 | "All `/api/*` routes are unauthenticated (no middleware, no per-route checks) — curl without a session cookie returns data" | **Refuted by adversarial review + parent verification.** `src/proxy.ts` IS the compiled Next.js 16 middleware (verified in `.next/server/functions-config-manifest.json`, `middleware.js`, and the compiled chunk; build output shows "ƒ Proxy (Middleware)"). It decrypts the iron-session cookie and enforces `isAuthenticated` + `sessionGeneration` on every path except `/login`, `/api/auth`, `/shared`, `/_next*`, `/favicon*`, and paths containing `.`. The auditor searched for `middleware.ts` (the Next 15 name) and missed `proxy.ts`. The residual truth is the dot-bypass (FIN-09). The claim that "no frontend code handles 401s because there is no auth" is also wrong — there is no 401 because the proxy redirects to /login. | +| QUIZ-06 (standalone) | "Quiz resume with stale question ids crashes the viewer" | Merged into FIN-04. The quiz-side path is currently unreachable (no question editing/deletion exists in the app; quiz deletion cascades progress), so it is a latent variant of the same root cause (dead `filterAndClampOrder`), not a separate live defect. | +| OPS-02 (original framing) | "Fresh-volume deployment crash-loops because `migrate deploy` fails" | **Weakened by adversarial review.** On a fresh volume `prisma migrate deploy` *succeeds* (verified against a temp DB); the fresh-volume failure is FIN-01 (schema drift → app 500s while the container runs). The crash loop requires a secondary trigger (db-push DB, tampering, lock). Reframed as FIN-22 with the crash-loop path demoted. | +| FE-01 (original severity) | Optimistic drag-drop reorder without rollback rated High | **Severity downgraded to Medium by adversarial review.** Mechanics confirmed (fire-and-forget fetch, in-place mutation, origin group omitted from cross-group payload) but impact analysis: sortOrder gaps only, silent divergence self-heals on reload, no visible break for a single user. Merged into FIN-18. | +| AUTH-06 (standalone) | "Cross-class group membership via reorder API exposes another class's content" | Merged into FIN-19 (same root cause: `groupId`/`sortOrder` never validated server-side). The sharing-exposure angle is recorded as impact of FIN-19. | +| DBAUD-04 | "Relative SQLite path resolution may cause dev dual-DB split-brain" | **Disproven.** CLI (`prisma.config.ts`) and app adapter both resolve `file:./dev.db` cwd-relative; `predev` and the app run from the project root → same file. Docker uses absolute `file:/app/data/study.db`. Recorded as verified-safe in UNVERIFIED_RISKS.md. | +| DBAUD-05/06/07, QUIZ-08-verdicts, GRP-01-verdicts (various) | "Cascades broken / transactions partial / StudyProgress duplicates via NULL tricks / id regeneration on card edit / XSS via markdown / client-server boundary violations" | **Disproven** by the respective auditors with evidence: FKs enforced by compile default; nested creates implicit-transactional; service-level guards block duplicates; PATCH edits in place (ids preserved); no `rehype-raw`; no server-module imports in client components. Recorded as verified-safe. | +| FE-11 (as bug) | "Collapsed-groups expand flash" | Retained only as Observation (FIN-51) — cosmetic, no data impact. | +| Various | Style preferences, harmless duplication, generic best practices, speculative micro-optimizations, Arcade-only issues | Excluded per audit scope rules. | diff --git a/audit-results/REMEDIATION_PLAN.md b/audit-results/REMEDIATION_PLAN.md new file mode 100644 index 0000000..6719e4a --- /dev/null +++ b/audit-results/REMEDIATION_PLAN.md @@ -0,0 +1,340 @@ +# Study Desk audit evaluation and remediation plan + +Date: 2026-08-06 +Scope: independent evaluation of `CONFIRMED_FINDINGS.md`, `FINDINGS.json`, the supporting audit reports, and the current repository source. +Change policy for this evaluation: this plan is the only new file; no application source, tests, migrations, configuration, generated files, database, or existing audit artifact was changed. + +## Executive verdict + +The audit is credible and the central findings hold up against the current source. The most urgent problems are not cosmetic: + +1. The committed migration chain does not create `MaterialGroup` or the three `groupId` columns, while the generated Prisma client selects those fields. A database created only with `prisma migrate deploy` is incompatible with the app. +2. Production authentication can use a public fallback session secret, making a deployment with an empty `SESSION_SECRET` forgeable. +3. The in-viewer quiz retake flow loses the retake scope and records the retake as a full, incorrectly scored attempt. +4. Flashcard resume trusts stale IDs and unguarded JSON, so deletion or malformed progress can leave the study viewer blank or crash it. +5. The production container path is presently unreliable: the port mapping is wrong without the development override, the conventional override silently changes normal Compose behavior, the build context can include host `node_modules`, and the runner installs a ranged Prisma CLI outside the lockfile. + +I agree with most of the remaining findings as defects or resilience gaps, but not always with their severity or proposed remedy. In particular: + +- FIN-04 needs more than wiring in the existing `filterAndClampOrder`: that helper does not preserve the logical current card when a stale ID occurs before the saved index, and it turns a completed index back into the last card. +- FIN-05 should normally use `3000:3726`, preserving the documented host port while mapping it to the actual container port. +- FIN-22 should not be fixed with an automatic migration retry loop. Schema conflicts should fail fast with actionable diagnostics and a documented recovery path. +- FIN-27 is more important than Low because changing quiz content can rewrite the meaning of persisted history. +- FIN-34 is only partially present: the dedicated deck page already awaits both DELETE requests, but the library start/restart path fires them without waiting. +- FIN-38 is not currently a defect for this Arizona-based installation. It becomes a portability requirement only if the app is meant to support a configurable local day boundary. +- FIN-42 does not justify a destructive history rewrite without evidence that the old database contained sensitive data and without coordinating every clone/remote. +- FIN-50 is an intentional convention. Newest named groups must remain first and Uncategorized must remain last; validation should make that convention harder to corrupt. +- FIN-52 is an architectural consequence of local grading in an anonymous read-only viewer. It merits a sharing warning, not removal of the answer key unless the product adopts server-side anonymous grading. + +## Finding-by-finding disposition + +Priority definitions: P0 = release/deployment blocker; P1 = next remediation batch; P2 = important hardening; P3 = worthwhile backlog; Conditional = verify a stated condition before changing behavior; No separate fix = intentional behavior or covered by another item. + +| ID | Verdict | Worth fixing? | Priority and disposition | +|---|---|---:|---| +| FIN-01 | Agree | Yes | **P0.** Add the missing migration plus a safe adoption runbook for already-`db push`-synchronized databases. | +| FIN-02 | Agree, conditional on an unset/blank production secret | Yes | **P0.** Production must fail before serving requests; development may use an explicitly development-only value. | +| FIN-03 | Agree | Yes | **P1.** Make attempted-question scope explicit and derive partial-retake status on the server. | +| FIN-04 | Agree; proposed helper is insufficient as written | Yes | **P1.** Normalize order, index, and results while preserving the logical current card and completed state. | +| FIN-05 | Agree | Yes | **P0.** Map host 3000 to container 3726 and smoke-test it. | +| FIN-06 | Agree with the mechanics; exact runtime failure is unconfirmed | Yes | **P0.** Add `.dockerignore` and prove native modules load in the Linux image. | +| FIN-07 | Agree | Yes | **P0.** Rename the development override and document explicit dev/prod commands. | +| FIN-08 | Partly agree: first-login setup is intentional, accidental exposure and dead config are real | Yes | **P1.** Honor a pre-provisioned admin hash or require an explicit one-time setup mode. | +| FIN-09 | Agree; latent with current IDs | Yes | **P1.** Replace the broad dot bypass with exact public/static path rules. | +| FIN-10 | Agree | Yes | **P1.** Prevent token overwrite, remove spoofable per-IP trust, and make reset initiation a local/admin operation. | +| FIN-11 | Agree | Yes | **P1.** Validate the whole attempt envelope, question scope, option ownership, array uniqueness, and types. | +| FIN-12 | Agree | Yes | **P1.** Add an in-flight guard, disable Finish, and surface failure/retry state. | +| FIN-13 | Agree as a credible timing race | Yes | **P1.** Persist the attempt and clear full-attempt progress in one server transaction. | +| FIN-14 | Agree as a credible timing race | Yes | **P2.** Add session identity and monotonic revisions, not only client debouncing. | +| FIN-15 | Agree | Yes | **P1.** Persist `currentIndex === order.length` and restore the summary state. | +| FIN-16 | Agree; impact is lower in a protected single-user app | Yes | **P2.** Accept only the queue-eligible card, including the intended learn-ahead rule. | +| FIN-17 | Agree that the queries are unbounded; user impact is unmeasured | Conditional | **P3.** Benchmark realistic data first, then optimize queries that exceed the budget. | +| FIN-18 | Agree | Yes | **P2.** Stop mutating shared objects, capture the origin group before mutation, await writes, and rollback/refetch on error. | +| FIN-19 | Agree | Yes | **P1.** Enforce class/type/group membership and server-owned ordering; retain a share-page defense. | +| FIN-20 | Agree | Yes | **P1.** Give the viewer a stable session input so unrelated parent renders cannot reset it. | +| FIN-21 | Agree | Yes | **P1.** Validate progress writes and defensively normalize legacy/corrupt progress on reads. | +| FIN-22 | Partly agree with the operational gap, not the retry remedy | No separate fix | Fail fast; cover CLI pinning in FIN-23 and recovery/health in FIN-24. Do not retry a conflicting migration automatically. | +| FIN-23 | Agree | Yes | **P0.** Use `npm ci` and ship a lockfile-pinned Prisma CLI/runtime path. | +| FIN-24 | Agree | Yes | **P1.** Add a schema-aware health check and tested backup/restore instructions. | +| FIN-25 | Agree | Yes | **P0/P1.** Gate test, build, Prisma validation/drift, and container smoke. Stage lint until its known baseline is resolved. | +| FIN-26 | Agree | Yes | **P1.** Return zero for an invalid zero-correct SATA question and test finite totals. | +| FIN-27 | Agree; severity should be Medium | Yes | **P2.** Persist an immutable result/review snapshot for new attempts, with legacy fallback. | +| FIN-28 | Agree | Yes | **P2.** Reuse a trimmed card-content schema and reject rather than silently drop incomplete Create-tab rows. | +| FIN-29 | Agree | Yes | **P2.** Add reasonable string, array, and request-size limits with clear 400/413 errors. | +| FIN-30 | Agree that the contract is inconsistent | Yes | **P2.** Make SATA imports require at least two correct options, matching the shipped generation instructions; preserve legacy stored data. | +| FIN-31 | Agree | Yes | **P2.** Validate complete request envelopes and map only actual not-found errors to 404. | +| FIN-32 | Agree | Yes | **P2.** Delete/reassign/renumber in one transaction and add deterministic item tie-breaks. | +| FIN-33 | Agree | Yes | **P2.** Add an explicit keyboard-accessible “Move to group” action; do not depend on drag gestures. | +| FIN-34 | Partly agree | Yes | **P2.** Fix the library path that does not await deletion; make deletion session-aware with FIN-14. | +| FIN-35 | Agree | Yes | **P3.** Translate concurrent first-review uniqueness conflicts to 409 and refetch. | +| FIN-36 | Agree as a credible timing race | Yes | **P2.** Disable navigation while grading and cancel/ignore stale animation callbacks. | +| FIN-37 | Agree | Yes | **P3.** Refresh set/deck membership on focus and after local deck-change events. | +| FIN-38 | Factually correct, but Arizona is the current intended boundary | Not now | Keep the existing behavior. Revisit only with an explicit portability requirement and a timezone setting/migration plan. | +| FIN-39 | Agree about the lost clear-cookie response; layout impact is overstated because proxy checks generation | Yes | **P2.** Destroy the cookie on the actual redirect response and test its header. | +| FIN-40 | Deployment-policy gap, not a bug on intentional HTTP | Yes, as documentation/config | **P1.** Document a TLS proxy profile and `SECURE_COOKIES=true`; do not enable Secure cookies on plain HTTP. | +| FIN-41 | Agree | Yes | **P1.** Validate a strict target enum and target existence; reject impossible/null-target rows. | +| FIN-42 | Agree that blobs exist; sensitivity is unproven | Conditional | Do not rewrite history now. Inventory the old DB offline before any public remote; rewrite only with explicit coordination if sensitive data is found. | +| FIN-43 | Partly agree | Yes | **P3.** Ignore `data/` and production DB sidecars. Confirm ownership, then remove unreferenced CSS scratch files. Do not automatically ignore the audit plan. | +| FIN-44 | Agree | Yes | **P2.** Add `res.ok`, error, and retry handling; never present a failed load as an empty dashboard. | +| FIN-45 | Agree | Yes | **P3.** Add abort/error handling to settings/share fetches. | +| FIN-46 | Agree as a credible timing race | Yes | **P2.** Use `AbortController` or a request generation tied to `classSlug`; remove or bound the module cache. | +| FIN-47 | Agree | Yes | **P3.** Show logout failure and retry; do not falsely claim logout if the server call failed. | +| FIN-48 | Partly agree | Yes | **P2.** Guarantee a non-empty unique slug. Keep slugs stable on rename unless redirect/history semantics are deliberately designed. | +| FIN-49 | Agree; impact is same-browser only | Yes | **P3.** Include the share token in local session keys and migrate/ignore old keys safely. | +| FIN-50 | Intentional newest-first convention | No separate fix | Preserve descending named-group order and final Uncategorized placement; restrict arbitrary sort-order writes under FIN-19/31. | +| FIN-51 | Agree, cosmetic only | Not now | Defer unless it can be removed without hydration mismatch or new effect-rule violations. | +| FIN-52 | Expected local-grading design | Warning only | **P3.** Explain in ShareMenu that recipients can inspect correct answers; do not imply answer secrecy. | +| FIN-53 | Agree | Yes | **P1.** Replace the false-confidence assertion and add scoring/scheduling edge cases. | + +## Remediation sequence + +### Phase 0 — safeguards and test foundation + +Do this before behavior or schema changes. + +1. Create a database-backed Vitest harness that always points Prisma at a unique disposable SQLite path. It must refuse to run if the resolved path is `dev.db`, `/app/data/study.db`, or any existing non-test database. +2. Add helpers to apply committed migrations to the disposable database and dispose of it after the test process. +3. Record the current lint baseline separately. Do not make a failing baseline a nominally “green” CI gate, and do not mix unrelated lint cleanup into P0 fixes. +4. Add focused pure tests first for scoring, progress normalization, import schemas, and rate limiting. These give fast feedback before route/service integration tests. +5. Before testing any real deployment database, make and verify a restorable backup. No remediation command should run against the only copy. + +Exit criteria: + +- Tests demonstrably create and use only a disposable DB. +- A deliberate attempt to point the harness at the real DB aborts. +- Existing `npm.cmd test` remains green. + +### Phase 1 — restore a safe, reproducible deployment path + +Addresses FIN-01, FIN-02, FIN-05–10, FIN-23–25, FIN-39, and FIN-40. + +#### 1A. Repair migration drift without breaking already-pushed databases + +1. Generate a new migration; never edit the four applied migrations. It must add: + - `MaterialGroup` with its Class cascade foreign key. + - nullable `groupId` on Deck and QuizSet with `ON DELETE SET NULL`. + - nullable unique `groupId` on ShareLink with `ON DELETE CASCADE`. + - the indexes Prisma expects. +2. Review the generated SQLite table-rebuild SQL by hand for preserved rows, foreign keys, defaults, and unique indexes. +3. Publish a one-time preflight/adoption script that classifies a database as: + - migration-tracked and missing the group schema: apply the migration normally; + - already schema-pushed and exactly matching the intended DDL: after backup and exact introspection, mark the new migration applied with `prisma migrate resolve --applied`; + - partially matching or otherwise inconsistent: stop with diagnostics and require manual recovery; never guess or auto-resolve. +4. Keep the adoption operation explicit. The normal entrypoint should not silently mutate migration history based on table existence. + +Verification: + +- Fresh empty DB: `prisma migrate deploy` succeeds; `prisma migrate diff --from-migrations ... --to-schema ...` is empty; Deck, QuizSet, ShareLink, and MaterialGroup queries succeed. +- Populated pre-group DB: seed classes/decks/quizzes/shares, apply the new migration, and prove every row/count/relationship is preserved. +- Disposable `db push`-style DB: introspect exact equivalence, resolve the migration, run deploy again, and prove an empty schema diff. +- Partial/conflicting DB: preflight exits nonzero without changing schema or `_prisma_migrations`. + +#### 1B. Make session configuration unforgeable and setup explicit + +1. Centralize session option construction so `src/lib/auth.ts` and `src/proxy.ts` cannot drift. +2. Require a nonblank, at-least-32-character production `SESSION_SECRET` at runtime. Do not require or bake the real secret during `docker build`. +3. Make the container entrypoint fail before migrations/server startup when the secret is absent or equals the known fallback. Make Compose use required-variable expansion. +4. Allow a development-only fallback only when `NODE_ENV !== "production"`, clearly label it non-production, and cover both branches with tests. +5. Replace `pathname.includes(".")` with exact public/static rules. Ensure every `/api/*` path other than the intentional auth endpoints remains protected even when the URL contains a dot or encoded dot. +6. Construct the unauthenticated redirect first and bind `getIronSession` to that response before `destroy()`, so the returned redirect carries the clearing cookie. +7. Honor a valid `ADMIN_PASSWORD_HASH` when the database has no configured password, or require an explicit one-time setup flag/token. The default production state must not let the first remote request select the admin password. +8. Prefer a local console/CLI initiation for password reset. If the HTTP request endpoint remains, use a global single-user throttle, never replace an unexpired token, rate-limit verify/complete, and do not trust arbitrary `x-forwarded-for` unless a trusted proxy is explicitly configured. + +Verification: + +- Production startup with missing, blank, short, or known fallback secret exits nonzero before serving. +- A cookie sealed with the old fallback does not authenticate when a real secret is configured. +- Valid login, logout, reset completion, and session-generation invalidation still work. +- Dot-containing protected page/API requests redirect or reject; known static assets still load. +- A stale-generation response contains a `Set-Cookie` deletion header. +- Pre-provisioned hash rejects a different first password and reports setup complete. + +#### 1C. Make the image and Compose definitions deterministic + +1. Add `.dockerignore` for `node_modules`, `.next`, VCS metadata, environment files, databases/data, coverage, temporary caches, and audit scratch artifacts while retaining source, Prisma schema/migrations, lockfile, and public assets. +2. Change the dependency stage to `npm ci`. +3. Remove `npm install prisma@^7.8.0` from the runner. Ship a Prisma CLI/runtime installed from `package-lock.json` at the exact repository version. If Prisma is needed at runtime, classify it as a runtime dependency and prune/copy dependencies deterministically rather than re-resolving them. +4. Map `3000:3726` in production Compose. Keep `PORT=3726` and `EXPOSE 3726` internally unless there is a deliberate decision to standardize everything on 3000. +5. Rename `docker-compose.override.yml` to a non-auto-merged development filename such as `docker-compose.dev.yml`. Document exact production and development invocations. +6. Add a minimal internal health route that performs schema-aware DB checks (including a query that touches `Deck.groupId` and MaterialGroup), returning no sensitive details. The container health check must call the actual internal port. +7. Add a lock-safe SQLite backup command using the SQLite backup API, plus a documented restore drill. A raw copy of only the main DB while WAL writes are active is not an acceptable backup procedure. + +Verification: + +- `docker compose -f docker-compose.yml config` shows the production command, production environment, and `3000:3726` only. +- The explicit development Compose command shows dev mode and source mounts. +- Build on a Windows-host context and run on Linux; requiring `better-sqlite3` and `argon2` succeeds in the final image. +- Fresh-volume container becomes healthy and `/login` returns 200 through host port 3000. +- A migration-drifted disposable volume stays unhealthy with an actionable log. +- Backup a populated disposable volume, destroy the disposable container/volume, restore it, and compare row counts plus representative content. + +#### 1D. Add CI gates in a sequence that can actually be green + +1. Gate `npm ci`, `npm test`, Prisma validate, migration/schema drift, and `npm run build` immediately. +2. Build the production image, start it on a fresh disposable volume with a generated CI secret, wait for health, and smoke `/login` plus setup status. +3. Tag immutable images by commit SHA; optionally move the mutable `latest` tag only after all gates pass. +4. Add strict lint only after the existing 9-error baseline is resolved in an explicit cleanup or after a changed-files lint gate is implemented. Never report baseline lint as passing. + +### Phase 2 — make quiz attempts authoritative and immutable + +Addresses FIN-03, FIN-11–13, FIN-20, FIN-26, FIN-27, and FIN-53. + +1. Replace the client-controlled `isPartialRetake` contract with an explicit ordered `questionIds` scope. The server validates that IDs are unique and belong to the quiz, then derives whether the attempt is partial. +2. Accept a structured answers object at the route boundary and persist only canonical server-serialized JSON. Validate that: + - each key is in the attempted scope; + - every selected option belongs to that question; + - selected IDs are unique; + - multiple-choice has at most one selection; + - missing answers score zero rather than silently disappearing. +3. Make the in-viewer “Retake Missed” and “Retake Full Quiz” set explicit local attempt scope. Do not infer retake state from the original prop after the viewer has transitioned internally. +4. Add an `isFinishing` state/ref, disable the Finish button during submission, and show a retryable error without discarding answers. +5. Move attempt creation and full-attempt SEQUENTIAL-progress deletion into one service transaction. Partial retakes must not overwrite or delete a full in-progress session. +6. Guard zero-correct SATA scoring with zero points and deduplicate selections defensively in the pure scorer even though the route also validates them. +7. Add an optional immutable review snapshot to QuizAttempt for new attempts: attempted question/order, prompt/category/rationale, options and correctness, selections, and per-question points. Render new history from that snapshot; keep a clearly tested legacy fallback for old rows. +8. Stabilize QuizViewer initialization. A parent render that changes topics or another surrounding control must not regenerate option order, clear answers, or reset the index. A deliberate restart/retake must use a new session key. + +Focused verification: + +- Full 5-question attempt scores out of 5 and clears only its matching full progress row. +- Two-question retake scores out of 2, is stored as partial, and leaves full progress untouched. +- An unanswered question in the explicit scope scores zero and remains in the review snapshot. +- Duplicate/unknown question or option IDs, wrong shapes, and client-forged partial flags return 400 without creating an attempt. +- Delayed double-click Finish produces exactly one attempt. +- Changing or deleting current quiz content after an attempt does not change the stored score, category breakdown, or review copy for snapshot-backed attempts. +- Parent rerender preserves question order, option order, current index, and answers; explicit restart changes only the intended session. + +### Phase 3 — make progress and flashcard resume race-safe + +Addresses FIN-04, FIN-14, FIN-15, FIN-21, FIN-34, and FIN-36. + +1. Replace or correct `filterAndClampOrder` with a normalization function that accepts saved order, live IDs, saved index, results/answers, and completion state. It must: + - remove stale and duplicate IDs; + - preserve the saved current ID when it still exists; + - when it was deleted, select the next surviving saved card, otherwise the previous survivor; + - preserve `index === oldOrder.length` as completion by returning `index === newOrder.length`; + - filter result/answer keys to live IDs; + - safely fall back when any JSON field is malformed. +2. Apply normalization before any viewer state initializer calls `JSON.parse`. Treat corrupt legacy progress as recoverable, show a small restore warning, and allow a fresh start. +3. Add Zod schemas for GET/PATCH/DELETE progress inputs, enum values, nonnegative index, content-specific JSON shape, and referenced content existence. +4. Add `sessionId` and monotonic `revision` to progress. The server accepts only a newer revision for the same session; a stale session cannot overwrite or delete a newer session. Serialize client saves but retain server-side revision enforcement because request completion order is not guaranteed. +5. On the last flashcard, persist `currentIndex = order.length`. Restoring that row must show the existing completion summary, not the last card. +6. Await restart deletion before remount/navigation. Make DELETE conditional on the session being cleared so a late old request cannot delete fresh progress. +7. Disable Previous, shuffle, and repeated grading during the 350 ms transition, and clear/cancel the timer on restart/unmount. + +Focused verification: + +- Stale ID before, at, and after the current index all resume on the correct logical card. +- All saved cards deleted yields a clear empty/completed state rather than a blank viewer. +- Completed sessions reopen on the summary; restart opens card 1. +- Invalid JSON and invalid PATCH payloads produce a safe UI fallback or 400, never a render crash/500. +- Deliver revisions 3, 1, and 2 in that order; the DB retains revision 3. +- Delay an old-session DELETE until after a new-session PATCH; the new session remains. +- Grade then immediately try Previous/restart; no index jump or stale result write occurs. + +### Phase 4 — enforce group, ordering, and sharing invariants on the server + +Addresses FIN-18, FIN-19, FIN-32, FIN-33, FIN-41, FIN-49, FIN-50, and FIN-52. + +1. Move reorder logic into focused services. The route must identify the owning class and content type from the database, not trust the client. +2. Validate every target group exists, belongs to the same class, and matches DECK/QUIZ type. Reject duplicate IDs, foreign-class IDs, type mismatches, and unknown IDs. +3. Prefer an ordered list/group assignment contract and compute contiguous `sortOrder` values server-side. If a complete-set contract is required, verify completeness explicitly. +4. In the library, capture origin group before any update, update state immutably, await `res.ok`, disable conflicting mutations in flight, and rollback/refetch on failure. +5. Delete a group in one transaction: capture affected items, delete/reassign through the FK behavior, then renumber Uncategorized items deterministically. Add `createdAt`/`id` tie-breakers to reads so legacy duplicates are stable. +6. Add a keyboard-accessible Move-to-group menu on every item. Preserve drag-and-drop for pointer users and keep drag listeners off action controls. +7. Validate ShareLink target type and target existence. Enforce exactly one populated target in service logic; consider CHECK constraints in a later SQLite migration after compatibility testing. +8. Retain the shared-page class/type/token checks and additionally assert each selected group item has the group’s class and expected content type. +9. Pass the share token/session namespace to shared viewers and include it in localStorage keys. Avoid copying an old item-only session into a different token namespace without explicit user confirmation. +10. Preserve named-group ordering as `sortOrder desc, createdAt desc`, prepend newly created groups locally, and render Uncategorized last. Remove arbitrary `sortOrder` from the ordinary group rename PATCH contract. +11. Add a concise ShareMenu warning that a shared quiz necessarily sends answer/rationale data to the recipient’s browser for local grading. + +Focused verification: + +- Cross-class and cross-type group assignment returns 400/409 and changes no rows. +- A failing reorder restores/refetches the visible order. +- Deleting a group yields unique contiguous Uncategorized item order and does not delete decks/quizzes. +- Newer named groups stay above older groups; Uncategorized remains last in quizzes, flashcards, and import selectors. +- A keyboard-only user can move an item between two groups and hear/see confirmation. +- Invalid share target types and missing IDs create zero rows. +- A tampered group share cannot render a foreign-class item. +- Two tokens for the same content maintain independent local sessions. + +### Phase 5 — validation, SRS integrity, and user-visible error handling + +Addresses FIN-16, FIN-28–31, FIN-35, FIN-37, FIN-43–48, and the measurable part of FIN-17. + +1. Reuse shared Zod schemas for card create/edit, deck/quiz create/edit, classes, imports, and material groups. Trim before minimum checks and add documented maximums for names, descriptions, Markdown content, options, questions, and cards. +2. Reject a Create-tab submission containing any partially filled card; identify the row(s) instead of silently dropping them. +3. Enforce at least two correct options for imported SATA questions while leaving existing stored questions readable. Add a targeted message explaining how to repair invalid generated JSON. +4. Narrow Prisma error mapping: P2025 becomes 404, P2002 becomes 409 where appropriate, validation is 400/422, and unexpected failures remain 500 with non-sensitive server diagnostics. +5. In SRS review, verify the submitted card is the queue-eligible card for the current set/time/new-card allowance (including deliberate learn-ahead). Retain the existing state-version comparison. Map concurrent first-review P2002 to 409 and return/refetch current study state. +6. Refresh SRS membership on focus and after same-tab deck changes. +7. Add abortable, checked fetch helpers or a small consistent pattern for Dashboard, GenerateTab, ShareMenu, logout, and class library requests. Distinguish loading, empty, error, and retry states. +8. Tie library responses to the active `classSlug`; abort or ignore stale responses. Bound or remove module-level caches that can outlive their class data. +9. Guarantee class slug generation produces a non-empty unique slug, for example a stable `class-` fallback. Keep existing slugs stable on rename. +10. Add `data/`, `study.db`, and sidecars to `.gitignore`. Confirm `out.css`, `temp.css`, and `test.css` are unreferenced scratch artifacts before removing them in a separate cleanup change. +11. For performance, first seed a disposable benchmark DB approximating expected upper use (for example 100k activity rows, 10k cards, and several SRS sets). Add date predicates/aggregation and narrower SRS queries only where the measured request/poll budget is exceeded. Preserve long-streak correctness when limiting the activity window. + +Focused verification: + +- Whitespace-only and oversized content is rejected with an actionable client error and no partial write. +- SATA 1-correct import fails; 2+-correct passes; multiple-choice still requires exactly one. +- SRS cannot review a not-due/non-selected/new-limit-exhausted card; valid due and learn-ahead reviews still work. +- Concurrent first review produces one success and one handled conflict, not a generic 500. +- Focus refresh removes a deleted deck from the SRS set UI. +- Failed dashboard/settings/share/logout requests have visible, retryable states and no unhandled promise rejection. +- Rapid A→B class navigation cannot render A data under B. +- Creating a class named only punctuation yields a navigable, unique slug; rename does not break the old URL. +- Performance tests record query counts and latency before/after and prove the 53-week display plus longer current streak remain correct. + +## Cross-cutting release verification + +Run after every phase, with focused tests first and the full suite last. + +Automated gates: + +1. `npm.cmd test` +2. `npx.cmd prisma validate` +3. migration deploy + empty migration/schema diff against a fresh disposable DB whenever schema changes +4. `npm.cmd run build` +5. `git diff --check` +6. production image build, fresh-volume health wait, and HTTP smoke when Docker/deployment files change +7. lint against changed files; full `npm.cmd run lint` only becomes blocking after the known baseline is cleared + +Manual browser matrix for affected phases: + +1. Authenticated routes and corresponding public/shared routes. +2. Fresh, resumed, completed, restarted, partial-retake, and stale/deleted-content sessions. +3. Loading, empty, server-error, malformed-persisted-data, and retry states. +4. Light and dark themes. +5. Narrow mobile viewport with no horizontal overflow. +6. Keyboard-only navigation, including group moves and all modified icon controls. +7. Throttled-network checks for Finish, autosave, restart, class navigation, reorder, and logout. +8. Two-tab checks for progress revisions, SRS conflicts, and focus refresh. + +Release/rollback rules: + +- Back up and restore-test the SQLite database before the first migration-bearing release. +- Deploy schema and code as one versioned release; do not run a newer generated Prisma client against an older database. +- Keep the prior image and verified pre-migration backup until post-deploy smoke and representative data checks pass. +- If migration preflight sees a partial or unknown schema state, stop. Do not run `db push`, edit an applied migration, reset the database, or auto-mark the migration applied. + +## Explicitly deferred or rejected work + +- **FIN-38:** no timezone-setting work without a product requirement to support a non-Arizona study day. +- **FIN-42:** no history rewrite without an offline sensitivity review and explicit remote/clone coordination. +- **FIN-51:** no cosmetic state-initialization change unless it avoids both hydration mismatch and the existing effect-rule class of lint failures. +- **FIN-22 retry proposal:** no automatic migration retry/backoff for schema conflicts; fail fast with health diagnostics and the adoption/recovery runbook. +- No changes to the audit’s already rejected hypotheses (global API auth absence, split-brain SQLite paths, broken cascades/transactions, Markdown XSS, or client/server import violations) unless new evidence appears. + +## Recommended delivery slices + +Keep the implementation reviewable rather than landing all remediation at once: + +1. **Release blocker:** FIN-01/02/05/06/07/23/25 plus migration adoption, container smoke, and backup prerequisites. +2. **Quiz correctness:** FIN-03/11/12/13/20/26/27/53. +3. **Progress integrity:** FIN-04/14/15/21/34/36. +4. **Group/share integrity:** FIN-18/19/32/33/41/49/50/52. +5. **Auth recovery and deployment posture:** FIN-08/09/10/24/39/40. +6. **Validation, SRS, UI resilience, and measured performance:** the remaining accepted items. + +Each slice should be independently releasable, have its own focused regression tests, and finish with the cross-cutting gates above. diff --git a/audit-results/SUBAGENT_REPORTS.md b/audit-results/SUBAGENT_REPORTS.md new file mode 100644 index 0000000..7fe4973 --- /dev/null +++ b/audit-results/SUBAGENT_REPORTS.md @@ -0,0 +1,20 @@ +# Subagent Reports + +Full specialist reports were produced by 10 read-only subagents. Each returned a structured markdown report with a findings table and per-finding detail blocks (ID, severity, confidence, files:lines, scenario, expected/actual, root cause, impact, evidence, test coverage, fix direction, complexity, regression test, runtime-confirmation flag). The parent agent re-verified the Critical/High claims directly (see VERIFICATION_LOG.md) and consolidated/deduplicated into `FINDINGS.json` (53 findings). + +## Report index (all reports were reviewed in full by the parent) + +| # | Auditor | Ref | Key output | +|---|---|---|---| +| 1 | DB / migrations / transactions / cascades / drift | sa_20260806_054030_000000000_5ed877563a0d | **DBAUD-01 Critical migration drift** (MaterialGroup/groupId never migrated; commit 7af0935 db-push artifact); DBAUD-02 junk ShareLink rows; DBAUD-03 dev.db in git history; verified-safe: cascades, transactions, path resolution, single-row AuthSecurity | +| 2 | Quiz scoring / attempts / retakes / progress | sa_20260806_054030_000000000_e75a8ce575f9 | **QUIZ-01 High retake-scoring bug** (in-viewer retake never sets retakeIds); QUIZ-02..09 (unvalidated answersJson, double-submit, progress races, dead filter, div-by-zero, historical recompute, multi-tab) | +| 3 | Flashcards / SRS / progress / deletion / ordering | sa_20260806_054030_000000000_a222804c133c | CARD-01..11: dead stale-id filter (stuck sessions), unsequenced saves, completed-session resume, SRS due/limit not enforced, unbounded scans, Arizona day boundary, restart race, P2002, animation race, focus staleness | +| 4 | Auth / protected routes / API authz / sharing | sa_20260806_054030_000000000_aab0c59dbad3 | **AUTH-01 Critical hardcoded session secret**; AUTH-02 dot-bypass; AUTH-03 reset abuse; AUTH-04 first-login takeover + dead ADMIN_PASSWORD_HASH; AUTH-05..10 (cookie-clear loss, cross-class group membership, share validation, secure flag, no auth tests, answer-key exposure); verified proxy compiled as middleware | +| 5 | Imports / exports / generation / Zod / malformed input | sa_20260806_054030_000000000_5e72c2713989 | IMPT-01..06: dead filterAndClampOrder, unvalidated /api/progress + unguarded JSON.parse, card edit endpoints without Zod, no size caps, SATA doc mismatch, override-field 500s; verified: shared schemas, atomic imports, no XSS, no id regeneration | +| 6 | Material groups / ordering / drag-drop / deletion / orphans | sa_20260806_054030_000000000_9ae1f3dfa282 | **GRP-01 Critical drift** (independent confirmation); GRP-02..07: unvalidated groupId/sortOrder, fire-and-forget reorder, sortOrder duplicates after group deletion, keyboard a11y, desc-convention fragility; verified: FKs enforced, uncategorized mapping, cascade safety | +| 7 | Frontend state / refresh / races / localStorage / boundaries | sa_20260806_054030_000000000_0e17fd155702 | FE-01..11: optimistic reorder without rollback, CRUD res.ok ignored, shared quiz reshuffle (unstable prop), finish double-submit, progress race, **FE-06 (later refuted — proxy exists)**, dashboard fetch gap, unguarded JSON.parse, effect fetches, cross-class race, collapse flash; verified: shared viewers read-only, localStorage clean, no boundary violations | +| 8 | Test quality / coverage / false confidence | sa_20260806_054030_000000000_cd3652deb3f7 | TEST-01..10: false-confidence SRS test, zero tests for scoring/auth/services/routes, no DB harness, CI never runs tests; ranked missing-test gaps by impact | +| 9 | Docker / startup / env / scripts / production / recovery | sa_20260806_054030_000000000_f49cdaacaabf | OPS-01..10: port mismatch 3726 vs 3000:3000, entrypoint fragility, session secret in prod path, dead ADMIN_PASSWORD_HASH, non-reproducible installs, no .dockerignore (win32 leak), no healthcheck/backup, override auto-merge, .gitignore gaps, CI without gates; argon2/standalone tracing verified OK | +| 10 | Adversarial review | sa_20260806_055608_000000000_c550c742d5dc | Challenged all Critical/High findings: confirmed drift, secret, retake, port, dockerignore, override; weakened OPS-02 (fresh-volume migrate succeeds) and FE-01 severity; **refuted FE-06** (proxy active — verified in compiled .next artifacts); added ADV-01..10 (retake progress pollution, activity scan, logout, dashboard crash, slugify empty slug, unguarded parses, completed-resume, localStorage key collisions, first-login race, progress validation) | + +All reports are preserved in full in the session; their findings are consolidated (deduplicated, source IDs preserved) in `FINDINGS.json` and `CONFIRMED_FINDINGS.md`. diff --git a/audit-results/TEST_GAPS.md b/audit-results/TEST_GAPS.md new file mode 100644 index 0000000..b77408b --- /dev/null +++ b/audit-results/TEST_GAPS.md @@ -0,0 +1,21 @@ +# Test Gaps + +Current suite: 6 files / 30 tests — 20 in `src/lib/arcade/*` (out of scope) + 10 in `src/lib/spacedRepetition.test.ts` (pure scheduling math only). **No test touches a service, an API route, the database, auth, scoring, imports, or progress.** CI never runs `npm test`. Gaps ranked by user-impact × regression-likelihood. + +| ID | Area | Gap | Risk it would fail to catch | Suggested test | +|---|---|---|---|---| +| TEST-GAP-01 | Auth (FIN-02/08/09/10) | `authService.ts` (login, lockout, first-login provisioning, reset token, generation bump) and session config have zero tests | Session-secret fallback regression, lockout bypass, generation-bump loss (stolen sessions stay valid) | Extract + unit-test `getLockoutDuration` thresholds, `parseResetTokenRecord`; service tests with temp SQLite + fake timers: 5 wrong passwords → 423; locked-out login rejected; success resets counter; `completePasswordReset` bumps generation | +| TEST-GAP-02 | Scoring (FIN-03/11/26/27) | `src/lib/scoring.ts` SATA partial credit has zero tests | Wrong grades silently persisted into every attempt — permanent data corruption | `scoreQuestion`: SATA 2/4 correct select both → 1; 1 correct + 1 wrong → 0; over-select → 0; MC multi-select first-wrong → 0; `scoreQuiz` with missing question keys; 0-correct SATA → 0 (not NaN) | +| TEST-GAP-03 | Quiz attempt persistence (FIN-03/12/13) | `POST /api/quizzes/[id]/attempt` + partial-retake filter + `progressService.upsertProgress` asymmetry (DECK writes cardResultsJson, QUIZ writes answersJson) have zero tests | Retake-scoring regression (FIN-03 class), duplicate attempts, progress column clobbering | Route/service test: partial retake answering 2 of 5 → maxScore 2, score from those 2; upsert round-trip preserves the respective JSON columns | +| TEST-GAP-04 | SRS service (FIN-16/35) | `spacedRepetitionService.ts` (reviewCard optimistic concurrency, findNextCard precedence, removeDeck transaction, addDeck validations) has zero tests — only the pure lib is tested | Double-review stale write, due/new-card precedence break, cross-class membership, P2002 → 500 | Seed class→deck→cards→set on temp DB: stale `expectedStateVersion` → 409; due before new before learn-ahead; new card with limit 0 rejected; removeDeck atomically deletes states | +| TEST-GAP-05 | Import/repair pipeline (FIN-28/29/30) | `jsonRepair.ts`, `importSchemas.ts`, `shuffle.ts`/`filterAndClampOrder` have zero tests | AI-import regression (fences/repair), schema constraint drift (MC exactly-one), resume clamp bugs | jsonRepair fence/trailing-comma/broken-JSON cases; schema whitespace/oversize/exactly-one cases; `filterAndClampOrder` stale-id + overflow cases | +| TEST-GAP-06 | Rate limiter (FIN-10) | `rateLimiter.ts` has zero tests | Brute-force guard silently disabled or locking the owner out | Fake timers: 10 allowed / 11th blocked with retryAfterMs; window expiry; per-key isolation; custom limits | +| TEST-GAP-07 | Integration harness (FIN-01/21/25) | No route tests, no DB-backed tests, no setup file; `src/lib/db.ts` defaults to `file:./dev.db` (a naive service test would touch the real dev DB) | Migration drift (FIN-01) — nothing would catch a schema/migration mismatch; progress/share/attempt route validation gaps | `setupFiles` + `DATABASE_URL=file:` + `prisma migrate deploy` in globalSetup; route tests with `NextRequest`: attempt 200/400/404, reviews 409, share unknown token, login 429 | +| TEST-GAP-08 | CI gate (FIN-25) | `.forgejo/workflows/build.yml` runs no tests/lint/prisma validate; no coverage config; no drift check | Entire suite can regress with a green pipeline; FIN-01-class drift ships | Add `npm ci && npm test && npm run lint && npx prisma validate` job; `prisma migrate diff --from-migrations --to-schema-datamodel` gate; container smoke (curl /login on fresh volume); coverage floor | +| TEST-GAP-09 | False confidence (FIN-53) | `spacedRepetition.test.ts:15-23` "distinct valid state for every rating" never compares ratings | `scheduleRating` collapsing all ratings to one schedule passes the suite | Assert pairwise due ordering AGAIN < HARD < GOOD < EASY and distinct due timestamps; graduated-state lapse case | + +## Test-quality positives (verified) + +- `spacedRepetition.test.ts` is deterministic (fixed `now`, `enable_fuzz: false`, no `Math.random`), tests the real pure module, and asserts exact FSRS intervals. +- Arcade tests (out of scope) use seeded shuffles and meaningful assertions. +- No test uses wall-clock time, locale, or network — no flakiness found. diff --git a/audit-results/UNVERIFIED_RISKS.md b/audit-results/UNVERIFIED_RISKS.md new file mode 100644 index 0000000..371613b --- /dev/null +++ b/audit-results/UNVERIFIED_RISKS.md @@ -0,0 +1,35 @@ +# Unverified Risks + +Risks that could not be fully confirmed within audit constraints (need a browser, a container run, a live deployment, or timing-dependent reproduction). All are code-verified as plausible; the missing piece is runtime confirmation. + +| ID | Title | Why unverified | Suggested verification | +|---|---|---|---| +| FIN-02 | Hardcoded fallback `SESSION_SECRET` → session forgery | Code path fully traced (seal/verify semantics verified in iron-session), but no live production deployment exists here to demonstrate an actual forged-cookie login | Boot production build without `SESSION_SECRET`, forge a cookie with the fallback password (iron-webcrypto seal script), assert full access; then with a real secret assert rejection | +| FIN-06 | No `.dockerignore` → win32 modules in Linux image | Mechanics verified (PE32+ DLL present in local node_modules; Dockerfile COPY order), exact failure mode (build error vs `ERR_DLOPEN_FAILED`) not observed | Run `docker build .` on this Windows host; inspect `better_sqlite3.node`/argon2 prebuilds in the image (must be ELF) | +| FIN-13 | Progress PATCH-after-DELETE race | Timing-dependent; needs throttled network | DevTools throttling: submit final answer + Finish immediately; inspect `StudyProgress` after | +| FIN-14 | Out-of-order progress PATCHes | Timing-dependent | DevTools throttling with rapid grading; reload and compare resume point/results | +| FIN-22 | Entrypoint migrate crash loop / runner CLI install | Requires container build + run on `node:22-slim` (docker CLI unavailable in audit environment) | `docker build` + fresh-volume run (assert 200 + `_prisma_migrations`); corrupt-volume run (assert actionable failure, not silent loop) | +| FIN-24 | Healthcheck/backup gaps | Behavior observable only in a real container run | Compose up with a corrupt volume; observe restart loop and absence of health status | +| FIN-32 | Group-delete sortOrder duplicates | Deterministic from code; visual impact needs a browser | Delete a group in the app, inspect Uncategorized order + DB sortOrders | +| FIN-33 | Keyboard cross-group moves impossible | Deterministic from code (empty `handleDragOver`), needs manual keyboard test | Tab to a drag handle, attempt keyboard cross-group move | +| FIN-34 | Restart DELETE race | Timing-dependent | Throttled network: Restart, grade first card, inspect progress row | +| FIN-36 | Previous-card animation race | Timing-dependent (350 ms window) | Grade then immediately click Previous; observe index jump | +| FIN-37 | SRS page stale on focus | Deterministic from code; browser needed to observe | Two tabs: delete deck in one, focus the other | +| FIN-39 | Proxy destroy cookie lost on redirect | Deterministic from code; browser needed to observe Set-Cookie | Stale-generation cookie → follow redirect → inspect response headers | +| FIN-40 | Secure cookie flag off | Deterministic from code (flag logic); deployment-dependent | Inspect `Set-Cookie` in a production container | +| FIN-46 | Cross-class fetch race | Timing-dependent | Throttled network + fast class switching | +| FIN-48 | Empty slug / stale slug on rename | Deterministic from code (slugify verified); UI behavior needs browser | Create class named "!!!", try to navigate to it; rename a class, check old URL | +| FIN-49 | Shared-viewer localStorage key collisions | Code-verified key construction; symptom needs browser | Open two share tokens for the same content, answer in one, reload the other | +| FIN-51 | Collapsed-groups expand flash | Cosmetic; browser-only | Visit library with saved collapsed state | + +## Items verified safe (hypotheses disproven — recorded for completeness) + +- SQLite relative-path split-brain between Prisma CLI and app (both cwd-relative → same `dev.db`; Docker uses absolute path) — verified safe. +- Foreign-key enforcement (better-sqlite3 compiled with `SQLITE_DEFAULT_FOREIGN_KEYS=1`) — cascades/SetNull fire; verified safe. +- Transactionality of deck/quiz/attempt/reorder writes — nested creates + `$transaction`; verified safe. +- `StudyProgress`/`ShareLink` NULL-uniqueness — service-level guards prevent duplicates via app paths; only direct API misuse (FIN-41/FIN-21) can create junk rows. +- Client/server boundary violations — none found (no client import of `@/lib/db`/services/prisma). +- XSS via Markdown — react-markdown without `rehype-raw`; no `dangerouslySetInnerHTML` on user data; verified safe. +- Password-reset nonce/token crypto (192-bit token, SHA-256 digest, `timingSafeEqual`, expiry) — sound; abuse vectors are FIN-10. +- SRS optimistic concurrency (`expectedStateVersion` → 409, rollback via refetch) — correct. +- Migrations are purely additive; no NOT NULL-without-default, no DROP — safe on populated migration-tracked DBs (except FIN-01 drift). diff --git a/audit-results/VERIFICATION_LOG.md b/audit-results/VERIFICATION_LOG.md new file mode 100644 index 0000000..da881a2 --- /dev/null +++ b/audit-results/VERIFICATION_LOG.md @@ -0,0 +1,38 @@ +# Verification Log + +Chronological record of every command run, its outcome, and classification (baseline / environment / possible defect). + +## Environment notes + +- OS: Windows, shell: bash (git-bash) +- `node_modules` was **absent** at audit start → `npm test` initially failed with `'vitest' is not recognized`. This is an environment condition, not a repo defect. `npm ci` completed successfully during the first audit session (518 packages present). +- No existing application database (`dev.db*`) exists anywhere in the repo — no real user data to protect; the migration-chain test uses a brand-new temp DB under `audit-results/tmp`. +- `src/generated/prisma/` (tracked) hashed BEFORE any prisma command: `audit-results/tmp/generated-before.sha256`. + +## Entries + +| # | Time | Command | Result | Classification | +|---|---|---|---|---| +| 1 | session start | `git status --short` | clean; only `?? .reasonix/` and `?? audit-results/` untracked | baseline | +| 2 | session start | `npm test` (first run, prior session) | FAILED: `'vitest' is not recognized` (no node_modules) | environment | +| 3 | session start | `npm ci` (background, prior session) | completed; 518 packages in node_modules | environment | +| 4 | 22:36 | `npx prisma validate` | PASS — "The schema at prisma\schema.prisma is valid" (exit 0) | baseline | +| 5 | 22:36 | `npm test` | PASS — 6 files, 30 tests, all green (arcade ×20, spacedRepetition ×10) | baseline | +| 6 | 22:37 | `npm run lint` | 28 problems: 9 errors, 19 warnings (react-hooks/set-state-in-effect errors + unused-vars warnings). All pre-existing; matches AGENTS.md known baseline. | baseline | +| 7 | 22:37 | `npm run build` (background) | PASS — route table emitted, no compile errors | baseline | +| 8 | 22:38 | `DATABASE_URL=file:./audit-results/tmp/audit-migration.db npx prisma migrate deploy` | PASS — all 4 migrations applied to brand-new temp DB | baseline | +| 9 | 22:38 | `sha256sum -c audit-results/tmp/generated-before.sha256` | PASS — every generated Prisma file byte-identical (all ": OK") | baseline | +| 10 | 22:38 | `git status --short` | only untracked `.reasonix/`, `audit-results/`; no tracked modifications | baseline | +| 11 | 22:38 | `git diff --check` | clean (no whitespace errors) | baseline | +| 12 | 22:40 | `git log --all --oneline -- prisma/migrations/` + `git show 7af0935 --stat` | Confirms drift: commit 7af0935 changed `prisma/schema.prisma` (+31) and `dev.db` (139264→155648 bytes) with **no migration**; `dev.db` present in 8 commits of history | possible defect (FIN-01, FIN-42) | +| 13 | 22:41 | `node audit-results/tmp/inspect-db.cjs audit-results/tmp/audit-migration.db` | Temp DB (built solely by `migrate deploy`) has **no** `MaterialGroup` table and **no** `groupId` column on Deck/QuizSet/ShareLink | possible defect (FIN-01) | +| 14 | 22:42 | `node audit-results/tmp/reproduce-drift.cjs` | Prisma-shaped queries fail: `no such column: Deck.groupId`, `no such table: MaterialGroup`, `no such column: ShareLink.groupId` — fresh-deploy breakage proven | possible defect (FIN-01) | +| 15 | 22:45 | `node --check audit-results/tmp/inspect-db.cjs && node --check audit-results/tmp/reproduce-drift.cjs` | diagnostic scripts syntax-valid | baseline | +| 16 | 22:50 | `node audit-results/tmp/validate-findings.cjs` | `FINDINGS.json` VALID JSON — 53 findings: 2 Critical, 5 High, 18 Medium, 25 Low, 3 Observation; no duplicate ids | baseline | +| 17 | 23:00 | `git status --short` + `git diff --check` (final) | Only untracked `.reasonix/`, `audit-results/`; no tracked file modified; diff clean | baseline | + +## Classification legend + +- **baseline** — repository behaves as expected; recorded for reference. +- **environment** — failure caused by local machine state, not repo code. +- **possible defect** — outcome may indicate a repo defect; investigated further in findings. diff --git a/audit-results/tmp/audit-migration.db b/audit-results/tmp/audit-migration.db new file mode 100644 index 0000000000000000000000000000000000000000..49fb7a25f2ecd497182091fcd1ca7755b944c223 GIT binary patch literal 221184 zcmeI*?T_0=eg|;T-j%#-uh&V=$Gh`6W|(u&kE^1-1rD1%J5A7Qi}qbx^jh4@d(jsM^hMEkeNh|^xEBRlv_RkVFX+3VK!Kn$ z6iJbixL&!5f$F{rc2^=tp`1hDC)rOIiHpNf$?FnlQaBn77Kfy;TNO6tZJ7qGT&kB#n=;$d++(%y zwmh{0zWUTU_({x*s*}bg zCsMn8+vs(J?=OavIJj%Z9DXn$7CX9^j^iN}ZSs(|KhzhzbTO=Qk7lMu_rJZnyDgWh z{Q?)v71IA6XR6=Ai9V?c{vOv4#3hN1BiVU~!9nn@yudcS9v zn>9LGHxxDz)ElLpJ@-_>j_aBz+OR!SgX!)Y8+ps@8#8v*_2oEUx)L2oClbTU20Eyp z|L6ya1b^)s_xQXM#w~irdu#^u-<%7F(%(K~9}QjvOmxteNiQNIdeMQ(Cc!VXgZXXv z(r08k4@&3aynHPx9;2#A{@bPFrNsn)`7-x-;KYPo2C$!aI1=vF!Ea)ZVoiTe-Dxcz z=Wkt(3ZGLEiTzF-4bCQbp68A$PSBjL>-<^>ht2ELv&7A64-O;xzzM_Pa~SPtekT{= z{9Ak!kkeX4a{eqHe~(M>lEghx93rJ}8dgVDI>zB}rnM%woC)Ji7NEn>XtBapEn_cd z;0P>RMq|(LYuY^;JZsPq!`};!H^ZVWqib0Ah_0w~tQx(%b->MGDZe$!Ff?w#X$Nfg z4(aGV@nS{YgPFZ{o1T!3fx)wmozk*ZlP*evCD=i|Qht;DS<78ZDYns}v$@*oyQfq8 z{kB05WZ9%|DOzv9MrFZYY&ENOx?+(xCl8ofPL|Ka`FEwLJeUleB28?gYvs$|qQAa! zX@&k?Sow?CYX9OtUzF$@ydVGp2tWV=5P$##AOHafK;U@`9KE!#+&o%Yyd8@zE@e`x zUet@~dMhgx*Rva$?7CXWZme%exlE3vb)x2q`C=}cNl6>2Rwhl$rZ-v{sgO(M^N+DaECHC4!_)2Vbml_?a8sf|L5Y-CkEEfu++{>z_j{iU<3AZ1fhioO(8r^{Va zQ+49({-dLp&pNegvRbJdX;RFz^z~d;C+pebhQ7XWK=k!Ou|SI1LN1#pEv=Z=QzVy7 zkyNIb$+wEx1NA`5&;q$kDlcjIoKEysdgCBVv|{dnWHMPbBaytC$r9;+-o#+F`a`!? znRF_Zb8BVwRgEs8P0}Z}!JZ#DCC#HNXPgS|vI^$a4LzU97S^SLs;_6$q_uuP($wxC zSIlHol^o>r2kC=6A=yG}Bcs!z8yot8uD1#sL|3(g3`uK+R`EdF$mBEmoT^eftx(8l zsf`2uKsv}}ROhrkw}NR&%6M!Rdn&A`nr*yq*pHlIt)msrsa=NEE>)CrTB?;_&*bxk z^{kf8tZ(Gfxph6CqG~D?$VqvUNpGYiN|egc8qt^*vZ?Gwv6XJ=8p-QKDyGy_Do^V} zYepoxRXRwkQmU29Qd!M8t>)A&&kjTbTU6-%72DZyYnf*C!s_>9tN*t8{nfu+ee%4M z9Or`o1Rwwb2tWV=5P$##AOHaf{J$5takj~w=ElteD|niFDQ8*1)7;ZIvv#(5s@n_f z`9J-a{_%nU1Rwwb2tWV=5P$##AOHafKwypq*zsFM#L&^B-fh5CRZ@00bZa0SG_<0uX=z1R#Lt z|HuIdKmY;|fB*y_009U<00Izz!2Ao~`TzXK7%hYV1Rwwb2tWV=5P$##AOHaf;Q2pt z00Izz00bZa0SG_<0uX=z1RyZ~0(kyE|1m}jApijgKmY;|fB*y_009U<00Maaj~svi z1Rwwb2tWV=5P$##AOHaf%)bDh|IdGn(LxA700Izz00bZa0SG_<0uX=zp8q2UAOHaf zKmY;|fB*y_009U<00Q$bfam}7A7iu-0uX=z1Rwwb2tWV=5P$##Ah5Ff-?2+vA-3|b ztG~GLi)NDiHy-uLSZS|>PPF?K_fX!p2ekEdVvld`^z^8sXam!v=czDE zQK(cK@*TN0s@bNzRcdZGgp_xNo~aY_J65k7Q7g4;eLzfWRKokBrkZ+45ad2Po{l#z!2Y1bw z!4C$+V)gKnaXg@+O&-$LhkAmSEQVF?(Y(~?{r|dveW*$iD04{k;&5gtvL-D1=4? zpV5P}qX(G2*`uRqM1)M0#a3-cjw`f3ANEYzBc}%VS>xp?&{(hVZDOP_o5 zLYyyOjp}oLmFRRc%!8HV%S#FV$`$Sj=SJ2!tJ~q1$K&c%X{PC+ty)%O&+4k3sJQ6F z(oDk{Acmpw5@D8zh?+?v$7{cXmYX#?>D%oxb@{YU%C<F9 z! zb2oWFHBDtt~wB=$RTG&q~!d7eA2I6-r|uJdal95$~{ z&k{GQJvfZ$11AiF&tbHq`JG&d^KbD{Ku&8F$@#N*{5>wgOA_})afp<@X;>Xq=@^H@ znbw-zawd#7S%3~hqZJ2RdW^lCfg`Z?7>zx{uW9#a@T@^c41X^?-VBSjjILqbBf6r} zv1;`4F6M6zOZlx)hM{o_PCH<`cSuL~i5DyC9?a~u+w_EN3=E!i?39+Rnsj9mEWr-y zmGYbH&sy$+NU@C$oz2xw-#wk$@3#$lAj>9wOVN4*HYyALim6$x(`AReIeEa;a68lN)eDD0m(&h7u=Y+(s6R#zH8vk|tw-^6r zF}?8jXa8>DM`xv#|6KX=*nh-+vht^~-=hD}*Zym9p4^VAM!m0Ugf7wg#5UNqU75~w zb(>D}bU7TgE4lNnIIrG1?IG!;Ivf?o59sb#`*H2n1b^!m_XoF}UJy|%V)JX^{t!`W zG&ftLg|Z(t?`lY=nICz1HPhgs>UzY&)=#-<7fQn+FxWY7h{Fos6#QlFlnbheRx8eT zr(RwSj~c#Co^q5~F12Q;*ARMY#oQQ#YsT_tma5QE_~?v~;N>;$=(=-@$hj+1(rn{> z!+xanG^fMUTMvq%KMd`A)y=0$byI%Nz3C9~J(1lt7~b#kN{Bba(Xqwk(bmh(J*1D` z8D=)S-{chyo%NNmEDjbNx~+%{yqQURO_1ud(k>uat}A z*XX-nkMl{M`#q;V!z&_oMaN*APv4w+;&8#?nVfC3+0F%F@&n#kfgj1k?nZ3e=pNRG zf4<=!)4NSO+&b!9^xUWDUGJ;y!T2hh_K)?iowUYuYe0L(@T6Dvz8dFWf1SJQRMWki zZIK59($&b=$FGN~J+|`(yP~>DznMHTFFQB-j@O)HmIn5{I-!Fc9qoeezZN>07~D0I zV8hJBJ>lS_RBASMD^+@^9lFx@Cp5{MMq8>uuhHE5@$`Cb;#l6zJpcZ3yQkeJdb4XA z?a{qp?{IVhZo3By-YTZ*$DNm)rTWLoVZ6P8z#mP%8g8z?J>1sNmFUzVRW%RE7j#$t z3%iNpH|9%Uvrn7YJ?g-?H0@TrKk$v+$8qlf(Kwv?hCGe8^p%tD9E?CD|1JNF&y9}B z3km+!SGkX0a59Tk*G<8|+ZV&x71%Rlem(GRYmN;-UJtOeo=FEC`j}~Z;+nOiKCBOS zk9RZg+v@nknTcZ<)*f9i8EU&mZ1p}p)9G%%YxGWWn?yaq@yCUU>xn7(6tH`)8AaDe zk|hvPFtSmUrF$p4EOQyR=|{N@hO|W70$pZC1%V- zZFomEA`efvwGw%Uxl2A^Yf5X6n42n{VaK!7Pm_#48_cxQ`ag}Q;;fG#$Cku2aL?(k z8~*!$b}s`j2tWV=5P$##AOHafKmY;|fWUkU;J^Q$@A#s15P$##AOHafKmY;|fB*y_ z00Aa|=l{q92tWV=5P$##AOHafKmY;|fWZ6sFM$94fBs{P7D5055P$##AOHafKmY;|fB*!r z{znc#00Izz00bZa0SG_<0uX=z1m<4=>;L(WFmI00Izz00bZa z0SG_<0uX?}{0rdu|NO@oErb9BAOHafKmY;|fB*y_009WF=l>UDZ^h1ke*WjnpDj0+ z{`%ZMom)-ZTKI>BkGX&6{wnrgbZ6A-=*@J3SJ${B&FJdnVae1~os?{wbo#cUI-mAb z?S4g9v|gv*CN|MawqoevlKFB?E;VGKQr(o_6U5UWMHF_cW97vgV(`4;O+gHtT1+0@ zloGu13U@>THD+~Gw6K-aoOJET-c8&KehUDr-Gx8#YnYf!%XHD`6=q^fCMR^*0mz+8X!iWm`RHlhDr4 zhm6~xM(OQsd7M+Yv9c`CmuTps(2(D22-RKs)7;(`_G*=#Qthtrj(qo~TSyIre5|-v zq|?($#4bzgnRH*J+K}(aHUC8ZH`RyrlS`HTTP2ll&I=j#I}v@q4gcB*NtefNvuKJKB@d)lia zi>KU_ZB{G$O@CI5EI$3{!`UYCOLX#aD#fOoK)vYvn0uYhkGHv_yH9CT;9G2dj6Bnu zZfo4hJ+fSghaT|h4G7eL*898PPVg3uMdB&-c4J|fmR%$78{`A;k|eTbZ=ALTk%xYI zTSmbMSBy3+olNk*O9S%msT!sa$cf=tpRF#bmV2C(P|kQy)cU^K-y}bUixjSk50HkWb?N^ zXg{r3o2X`&UMALU(w}m)$tl>Fz^mQ?n_@@%GcU+b3LNx5h@FNkqRwQP-6so#ot- zbrb{d^w0HrATk%d8FR{|?J)1cmj>gRM;u)#1ae!McJUI}PcN;5 zSKeFR)x|7zqZi(I{vUO71KS}00SG_<0uX=z1Rwwb2tWV=^CN)u|NO)fEr9?8AOHaf zKmY;|fB*y_009U@31IynC4=n{fB*y_009U<00Izz00bZaf%y@@`hR|6iIzYB0uX=z z1Rwwb2tWV=5P$##q6D!1kCMT52tWV=5P$##AOHafKmY;|fWZ6+VEsQou|!KC009U< z00Izz00bZa0SG_<0#O3&|Nj@e6tx%IApijgKmY;|fB*y_009U<00I!02LU|)pNA-- z4G@3;1Rwwb2tWV=5P$##AOL}r1@Qd;WFPE>00bZa0SG_<0uX=z1Rwwb2+V^3p8wB7 z6ww9 d9-@dgKmY;|fB*y_009U<00Izz00d4J_+O78JFfr$ literal 0 HcmV?d00001 diff --git a/audit-results/tmp/generated-before.sha256 b/audit-results/tmp/generated-before.sha256 new file mode 100644 index 0000000..ec530fd --- /dev/null +++ b/audit-results/tmp/generated-before.sha256 @@ -0,0 +1,26 @@ +cb737289f5b5f6cdb8b90fc23d2c89ebd41d6602ce99e88bf6dfd751beab7103 *src/generated/prisma/browser.ts +2ea331124cc56c9c9a360b4a85f42f029992811ece7efe9db0ec1300a43f59c3 *src/generated/prisma/client.ts +f1f6280ff65c1e8aab5ce30307c8777ce1795f81bf1a513396263fc1c9a30e5c *src/generated/prisma/commonInputTypes.ts +8bcc37ae19ee9c55424b735ce5f3a0972b7e741958ef86b350c9a54dc2238a0d *src/generated/prisma/enums.ts +d52628a3dc8285d9014e2fda489badce6c88604810e2975310ab6c40ec3c708d *src/generated/prisma/internal/class.ts +91794eb9b3c395e57b96d18f2bae895149f7b70ddf0fda8b817e7aa9da17a950 *src/generated/prisma/internal/prismaNamespace.ts +6645109792ed08507d79da319aaecc2752bffa6e8bd5a3364dccc5ae93e2f23c *src/generated/prisma/internal/prismaNamespaceBrowser.ts +b8ffef1ad4428179847aafe66abee3ad5eb74a84fff46265532551b10ab2537d *src/generated/prisma/models.ts +daecbb1c94a96d19da4accfdf81fe8f11fa73c543958016bc7d6060d454f3943 *src/generated/prisma/models/AnswerOption.ts +87e18727cec17a341d32709bc0c1602c66e6c6dcc57672b9c9fbe38a6ce12801 *src/generated/prisma/models/ArcadeAttempt.ts +0c9de62bdbefafe8b5ff5300d1d764cc73235a077f8e3b3e03e6144287610c9d *src/generated/prisma/models/ArcadePack.ts +67b8ea01f0a866d14db7fdff3178a080e2879990129c5e08690463fb9940f48d *src/generated/prisma/models/AuthSecurity.ts +6885465a57019e8cba28755d9c6abaa087e913d666d02d8912abf0790c41a77b *src/generated/prisma/models/Class.ts +ea873177511c6f7354dc01ec0ad228d9f777bca2fbfb15d281d3f2ab8f817ebf *src/generated/prisma/models/Deck.ts +1082ff29bec6e4f920c93d778032ffffa324c312b05efd6c5fa7ada1be6674f7 *src/generated/prisma/models/Flashcard.ts +de94c2da405c08a0b690aeb1b0cdc03582c94ccddaf913f4c6d1b18d8c413b61 *src/generated/prisma/models/MaterialGroup.ts +941998d2c098e9fe5cd0a888bac470190108238c936c1d7cfce2742ed1e40d8a *src/generated/prisma/models/Question.ts +3d54ed93e516f4d773c200b166a84ab2c9c8b7b0d7f7ddfd61afc3ca057ee755 *src/generated/prisma/models/QuizAttempt.ts +454f19f97a82b8f37fc5c5179e87afe6ea185a79810043471d55c0a8ca26f14a *src/generated/prisma/models/QuizSet.ts +3b884aaaad311e8f22c1390cf24ebe69b74b52a97c213781d2a4cbb56288b8a7 *src/generated/prisma/models/Setting.ts +1ccf32b2f56b1d35c9b7152d2e1817b4c1b7853549a1ff187e9fb7c29fa49f2a *src/generated/prisma/models/ShareLink.ts +cba950d431c702afe67f3ac92d8397275d3aeeebbc59d357fd20dec2123e0ee2 *src/generated/prisma/models/SpacedRepetitionCardState.ts +2ef43d1c5d7e2eb0d85080fb29703ae99c1704ba8f5200703e2bb8e7a617bf04 *src/generated/prisma/models/SpacedRepetitionSet.ts +0bf9eab9f937ef2d621ccf0cea8e209eae9372a9b333dfe6b18267e563a6b2b0 *src/generated/prisma/models/SpacedRepetitionSetDeck.ts +916bda8f95647f0dc3560bc6848c4a97d4c1f37fd5ed54e6736d9d7d45e08adc *src/generated/prisma/models/StudyActivity.ts +36724157e76b49df49e7827eacbcb0ab8336917f35913777701d8a6830770500 *src/generated/prisma/models/StudyProgress.ts diff --git a/audit-results/tmp/inspect-db.cjs b/audit-results/tmp/inspect-db.cjs new file mode 100644 index 0000000..7e4af48 --- /dev/null +++ b/audit-results/tmp/inspect-db.cjs @@ -0,0 +1,52 @@ +// Read-only diagnostic: introspect the temp DB created by `prisma migrate deploy` +// to check whether the migrated schema matches schema.prisma (drift check). +// Usage: node audit-results/tmp/inspect-db.cjs +const path = require("node:path"); +const Database = require("better-sqlite3"); + +const dbPath = process.argv[2]; +if (!dbPath) { + console.error("usage: node inspect-db.cjs "); + process.exit(2); +} + +const db = new Database(path.resolve(dbPath), { readonly: true }); + +const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .all() + .map((r) => r.name); + +console.log("=== TABLES ==="); +console.log(tables.join(", ")); +console.log("Has MaterialGroup table:", tables.includes("MaterialGroup")); + +for (const table of ["Deck", "QuizSet", "ShareLink", "MaterialGroup"]) { + if (tables.includes(table)) { + const cols = db + .prepare(`PRAGMA table_info('${table}')`) + .all() + .map((r) => r.name); + console.log(`--- ${table} columns ---`); + console.log(cols.join(", ")); + if (table === "Deck" || table === "QuizSet" || table === "ShareLink") { + console.log(` -> has groupId:`, cols.includes("groupId")); + } + } else { + console.log(`--- ${table}: MISSING TABLE ---`); + } +} + +const indexes = db + .prepare("SELECT name, tbl_name FROM sqlite_master WHERE type='index' ORDER BY name") + .all() + .map((r) => `${r.tbl_name}.${r.name}`); +console.log("=== INDEXES (count) ===", indexes.length); + +// Confirm migrations table +const applied = db.prepare("SELECT migration_name FROM _prisma_migrations ORDER BY started_at").all(); +console.log("=== APPLIED MIGRATIONS ==="); +applied.forEach((m) => console.log(" -", m.migration_name)); + +db.close(); +console.log("DONE"); diff --git a/audit-results/tmp/reproduce-drift.cjs b/audit-results/tmp/reproduce-drift.cjs new file mode 100644 index 0000000..ba3d99f --- /dev/null +++ b/audit-results/tmp/reproduce-drift.cjs @@ -0,0 +1,40 @@ +// Read-only diagnostic: execute the exact SQL shape the generated Prisma client +// emits for deck.findMany() / materialGroup.findMany() against a DB built purely +// from `prisma migrate deploy` (predev + Docker entrypoint flow). +// Proves DBAUD-01: migrated DB is incompatible with the shipped client. +const path = require("node:path"); +const Database = require("better-sqlite3"); + +const dbPath = path.resolve("audit-results/tmp/audit-migration.db"); +const db = new Database(dbPath, { readonly: true }); + +// 1. Shape of prisma.deck.findMany({ include: { class: true } }) — Prisma selects +// every scalar field, including groupId (schema.prisma:33-34, generated client). +const deckSql = + 'SELECT "Deck"."id", "Deck"."classId", "Deck"."name", "Deck"."description", "Deck"."sortOrder", "Deck"."createdAt", "Deck"."groupId" FROM "Deck"'; +try { + db.prepare(deckSql).all(); + console.log("deck.findMany SQL: OK"); +} catch (e) { + console.log("deck.findMany SQL: FAILED ->", e.message); +} + +// 2. Shape of prisma.materialGroup.findMany() +const groupSql = 'SELECT "MaterialGroup"."id", "MaterialGroup"."classId", "MaterialGroup"."name", "MaterialGroup"."type", "MaterialGroup"."sortOrder", "MaterialGroup"."createdAt" FROM "MaterialGroup"'; +try { + db.prepare(groupSql).all(); + console.log("materialGroup.findMany SQL: OK"); +} catch (e) { + console.log("materialGroup.findMany SQL: FAILED ->", e.message); +} + +// 3. Shape of prisma.shareLink.findFirst() (share page validation) +const shareSql = 'SELECT "ShareLink"."id", "ShareLink"."targetType", "ShareLink"."deckId", "ShareLink"."quizSetId", "ShareLink"."groupId", "ShareLink"."createdAt" FROM "ShareLink" LIMIT 1'; +try { + db.prepare(shareSql).all(); + console.log("shareLink.findFirst SQL: OK"); +} catch (e) { + console.log("shareLink.findFirst SQL: FAILED ->", e.message); +} + +db.close(); diff --git a/audit-results/tmp/validate-findings.cjs b/audit-results/tmp/validate-findings.cjs new file mode 100644 index 0000000..ffc724a --- /dev/null +++ b/audit-results/tmp/validate-findings.cjs @@ -0,0 +1,27 @@ +// Validates audit-results/FINDINGS.json parses and reports counts per severity. +const fs = require("node:fs"); +const path = require("node:path"); + +const file = path.resolve("audit-results/FINDINGS.json"); +const raw = fs.readFileSync(file, "utf8"); +const data = JSON.parse(raw); // throws if invalid + +const findings = data.findings; +const bySeverity = {}; +for (const f of findings) { + bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1; +} +const byConfidence = {}; +for (const f of findings) { + byConfidence[f.confidence] = (byConfidence[f.confidence] || 0) + 1; +} + +const ids = findings.map((f) => f.id); +const dupes = ids.filter((id, i) => ids.indexOf(id) !== i); +if (dupes.length) throw new Error("duplicate ids: " + dupes.join(",")); + +console.log("FINDINGS.json: VALID JSON"); +console.log("total findings:", findings.length); +console.log("by severity:", JSON.stringify(bySeverity)); +console.log("by confidence:", JSON.stringify(byConfidence)); +console.log("ids:", ids.join(", ")); diff --git a/benchmarks/remediationBenchmark.test.ts b/benchmarks/remediationBenchmark.test.ts new file mode 100644 index 0000000..25eb390 --- /dev/null +++ b/benchmarks/remediationBenchmark.test.ts @@ -0,0 +1,149 @@ +import { performance } from "node:perf_hooks"; +import path from "node:path"; +import Database from "better-sqlite3"; +import { afterAll, describe, expect, it } from "vitest"; +import { prisma } from "@/lib/db"; +import { getActivitySummary } from "@/services/activityService"; +import { listClasses } from "@/services/classService"; +import { getStudyAvailabilityByClass } from "@/services/spacedRepetitionService"; + +const ACTIVITY_ROWS = 100_000; +const CARD_ROWS = 10_000; +const SETS = 5; +const REQUEST_BUDGET_MS = 250; +const NOW = new Date("2026-08-07T18:00:00.000Z"); + +afterAll(async () => { + await prisma.$disconnect(); +}); + +function databasePath() { + const url = process.env.DATABASE_URL; + if (!url?.startsWith("file:./")) { + throw new Error("Benchmark requires the disposable Vitest database"); + } + return path.resolve(process.cwd(), url.slice("file:".length)); +} + +function seedRealApplicationSchema() { + const database = new Database(databasePath()); + try { + database.pragma("foreign_keys = ON"); + const insertActivity = database.prepare( + 'INSERT INTO "StudyActivity" ("id", "type", "occurredAt") VALUES (?, ?, ?)' + ); + const insertDeck = database.prepare( + 'INSERT INTO "Deck" ("id", "classId", "name", "sortOrder") VALUES (?, ?, ?, ?)' + ); + const insertSet = database.prepare( + 'INSERT INTO "SpacedRepetitionSet" ("id", "classId", "name", "newCardsPerDay", "sortOrder", "updatedAt") VALUES (?, ?, ?, ?, ?, ?)' + ); + const insertMembership = database.prepare( + 'INSERT INTO "SpacedRepetitionSetDeck" ("setId", "deckId", "sortOrder") VALUES (?, ?, ?)' + ); + const insertCard = database.prepare( + 'INSERT INTO "Flashcard" ("id", "deckId", "front", "back", "sortOrder") VALUES (?, ?, ?, ?, ?)' + ); + const insertState = database.prepare(` + INSERT INTO "SpacedRepetitionCardState" + ("id", "setId", "flashcardId", "due", "stability", "difficulty", "elapsedDays", "scheduledDays", "learningSteps", "reps", "lapses", "state", "firstReviewedAt", "lastReview", "createdAt", "updatedAt") + VALUES (?, ?, ?, ?, 1, 5, 1, ?, 0, 1, 0, ?, ?, ?, ?, ?) + `); + database.transaction(() => { + database.prepare( + 'INSERT INTO "Class" ("id", "slug", "name", "sortOrder") VALUES (?, ?, ?, 0)' + ).run("benchmark-class", "benchmark-class", "Benchmark Class"); + + for (let index = 0; index < ACTIVITY_ROWS; index += 1) { + const ageDays = index % 730; + const type = index % 3 === 0 + ? "FLASHCARD" + : index % 3 === 1 + ? "QUIZ_QUESTION" + : "ARCADE_GROUP"; + insertActivity.run( + `activity-${index}`, + type, + NOW.getTime() - ageDays * 86_400_000 + ); + } + + for (let setIndex = 0; setIndex < SETS; setIndex += 1) { + const deckId = `benchmark-deck-${setIndex}`; + const setId = `benchmark-set-${setIndex}`; + insertDeck.run(deckId, "benchmark-class", `Deck ${setIndex}`, setIndex); + insertSet.run(setId, "benchmark-class", `Set ${setIndex}`, 30, setIndex, NOW.getTime()); + insertMembership.run(setId, deckId, 0); + for (let cardIndex = setIndex; cardIndex < CARD_ROWS; cardIndex += SETS) { + const cardId = `benchmark-card-${cardIndex}`; + const due = NOW.getTime() + ((cardIndex % 200) - 100) * 60_000; + const firstReviewed = NOW.getTime() - (cardIndex % 30) * 86_400_000; + const scheduledDays = cardIndex % 4 === 0 ? 1 : 0; + const state = cardIndex % 4; + insertCard.run(cardId, deckId, `Front ${cardIndex}`, `Back ${cardIndex}`, cardIndex); + insertState.run( + `benchmark-state-${cardIndex}`, + setId, + cardId, + due, + scheduledDays, + state, + firstReviewed, + firstReviewed, + firstReviewed, + NOW.getTime() + ); + } + } + })(); + } finally { + database.close(); + } +} + +async function measure(label: string, operation: () => Promise) { + const start = performance.now(); + const value = await operation(); + return { + label, + latencyMs: Number((performance.now() - start).toFixed(2)), + value, + }; +} + +describe("remediation performance condition", () => { + it("measures actual migrated services at the expected upper-use fixture", async () => { + seedRealApplicationSchema(); + const activity = await measure("getActivitySummary", () => getActivitySummary(NOW)); + const availability = await measure("getStudyAvailabilityByClass", () => + getStudyAvailabilityByClass(NOW) + ); + const classPolling = await measure("listClasses/navbar-poll", () => listClasses()); + const measurements = [activity, availability, classPolling].map((result) => ({ + label: result.label, + latencyMs: result.latencyMs, + })); + + console.info(JSON.stringify({ + fixture: { activityRows: ACTIVITY_ROWS, cards: CARD_ROWS, sets: SETS }, + budgetMs: REQUEST_BUDGET_MS, + prismaServiceOperations: { activity: 1, availability: 1, classPolling: 2 }, + measurements, + activity: { + displayedDays: activity.value.days.length, + currentStreak: activity.value.currentStreak, + }, + readyCards: availability.value["benchmark-class"]?.readyCards, + }, null, 2)); + + expect(activity.value.days).toHaveLength(53 * 7); + expect(activity.value.currentStreak).toBeGreaterThan(53 * 7); + expect(availability.value["benchmark-class"]).toBeDefined(); + expect(classPolling.value).toHaveLength(1); + for (const measurement of measurements) { + expect(measurement.latencyMs, measurement.label).toBeLessThanOrEqual( + REQUEST_BUDGET_MS + ); + } + }); +}); diff --git a/docker-compose.override.yml b/docker-compose.dev.yml similarity index 70% rename from docker-compose.override.yml rename to docker-compose.dev.yml index 224ac4a..7721dc8 100644 --- a/docker-compose.override.yml +++ b/docker-compose.dev.yml @@ -1,12 +1,15 @@ services: study-app: build: + context: . target: deps command: npm run dev + ports: + - "3000:3000" + environment: + - DATABASE_URL=file:/app/data/study.db + - NODE_ENV=development volumes: - .:/app - /app/node_modules - environment: - - NODE_ENV=development - ports: - - "3000:3000" + - ./data:/app/data diff --git a/docker-compose.yml b/docker-compose.yml index 6270daf..3ca99f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,11 +4,13 @@ services: container_name: study-app restart: unless-stopped ports: - - "3000:3000" + - "3000:3726" environment: - DATABASE_URL=file:/app/data/study.db - - SESSION_SECRET=${SESSION_SECRET} - - ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH} + - SESSION_SECRET=${SESSION_SECRET:?SESSION_SECRET must be set to at least 32 characters} + - ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH:-} + - ALLOW_INITIAL_SETUP=${ALLOW_INITIAL_SETUP:-false} + - SECURE_COOKIES=${SECURE_COOKIES:-false} - NODE_ENV=production volumes: - ./data:/app/data diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 5e93fe8..0678c2c 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,4 +1,12 @@ #!/bin/sh set -e -npx prisma migrate deploy + +DEFAULT_SECRET='dev-session-secret-change-in-production-must-be-32-chars' +TRIMMED_SECRET=$(printf '%s' "${SESSION_SECRET:-}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') +if [ -z "$TRIMMED_SECRET" ] || [ "${#TRIMMED_SECRET}" -lt 32 ] || [ "$TRIMMED_SECRET" = "$DEFAULT_SECRET" ]; then + echo >&2 "SESSION_SECRET must be a non-default value of at least 32 characters" + exit 1 +fi + +./node_modules/.bin/prisma migrate deploy exec node server.js diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..3d021d4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,7 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + "audit-results/tmp/**", ]), ]); diff --git a/out.css b/out.css deleted file mode 100644 index c837bac..0000000 --- a/out.css +++ /dev/null @@ -1 +0,0 @@ -:root { --color-primary: #fff; } diff --git a/package-lock.json b/package-lock.json index f81787d..c9163a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "better-sqlite3": "^12.11.1", "iron-session": "^8.0.4", "jsonrepair": "^3.14.1", - "next": "16.2.9", + "next": "16.3.0", + "prisma": "^7.8.0", "react": "19.2.4", "react-dom": "19.2.4", "react-markdown": "^10.1.0", @@ -27,12 +28,12 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/better-sqlite3": "^9.6.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.9", - "prisma": "^7.8.0", "tailwindcss": "^4", "typescript": "^5", "vitest": "^4.1.10" @@ -82,7 +83,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -309,7 +309,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -350,15 +349,12 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", - "devOptional": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-socket": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", - "devOptional": true, "license": "Apache-2.0", "bin": { "pglite-server": "dist/scripts/server.js" @@ -371,12 +367,46 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", - "devOptional": true, "license": "Apache-2.0", "peerDependencies": { "@electric-sql/pglite": "0.4.1" } }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -542,7 +572,6 @@ "version": "1.19.11", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=18.14.1" @@ -628,9 +657,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -640,19 +669,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -662,19 +691,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -688,9 +736,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -704,9 +752,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -720,9 +768,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -736,9 +784,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ "ppc64" ], @@ -752,9 +800,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ "riscv64" ], @@ -768,9 +816,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -784,9 +832,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -800,9 +848,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -816,9 +864,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -832,9 +880,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -844,19 +892,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -866,19 +914,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ "ppc64" ], @@ -888,19 +936,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ "riscv64" ], @@ -910,19 +958,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -932,19 +980,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -954,19 +1002,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -976,19 +1024,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -998,38 +1046,54 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@emnapi/runtime": "^1.11.1" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ "arm64" ], @@ -1039,16 +1103,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -1058,16 +1122,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -1077,7 +1141,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1137,7 +1201,6 @@ "version": "0.3.4", "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", - "devOptional": true, "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { @@ -1160,9 +1223,9 @@ } }, "node_modules/@next/env": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.9.tgz", - "integrity": "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz", + "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1176,9 +1239,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.9.tgz", - "integrity": "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz", + "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==", "cpu": [ "arm64" ], @@ -1192,9 +1255,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.9.tgz", - "integrity": "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz", + "integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==", "cpu": [ "x64" ], @@ -1208,9 +1271,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.9.tgz", - "integrity": "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz", + "integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==", "cpu": [ "arm64" ], @@ -1224,9 +1287,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.9.tgz", - "integrity": "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz", + "integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==", "cpu": [ "arm64" ], @@ -1240,9 +1303,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.9.tgz", - "integrity": "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz", + "integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==", "cpu": [ "x64" ], @@ -1256,9 +1319,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.9.tgz", - "integrity": "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz", + "integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==", "cpu": [ "x64" ], @@ -1272,9 +1335,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.9.tgz", - "integrity": "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz", + "integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==", "cpu": [ "arm64" ], @@ -1288,9 +1351,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.9.tgz", - "integrity": "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz", + "integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==", "cpu": [ "x64" ], @@ -1414,7 +1477,6 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "c12": "3.3.4", @@ -1433,7 +1495,6 @@ "version": "0.24.3", "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", - "devOptional": true, "license": "ISC", "dependencies": { "@electric-sql/pglite": "0.4.1", @@ -1468,7 +1529,6 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", - "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -1482,14 +1542,12 @@ "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.8.0" @@ -1499,7 +1557,6 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.8.0", @@ -1511,7 +1568,6 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.8.0" @@ -1521,7 +1577,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.2.0" @@ -1531,21 +1586,18 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/query-plan-executor": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/streams-local": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "ajv": "^8.12.0", @@ -1562,7 +1614,6 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1579,14 +1630,12 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "devOptional": true, "license": "MIT" }, "node_modules/@prisma/studio-core": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@radix-ui/react-toggle": "1.1.10", @@ -1606,14 +1655,12 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", - "devOptional": true, "license": "MIT" }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1629,7 +1676,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.2.3" @@ -1653,7 +1699,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -1672,7 +1717,6 @@ "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", @@ -1698,7 +1742,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", - "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", @@ -1718,7 +1761,6 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", - "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" @@ -1737,7 +1779,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", - "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2047,7 +2088,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, "license": "MIT" }, "node_modules/@swc/helpers": { @@ -2282,6 +2322,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", @@ -2341,6 +2447,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/better-sqlite3": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2427,7 +2543,6 @@ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2437,7 +2552,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2446,9 +2560,8 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2504,7 +2617,6 @@ "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/types": "8.62.0", @@ -3227,7 +3339,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3518,7 +3629,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 6.0.0" @@ -3597,7 +3707,6 @@ "version": "2.9.2", "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz", "integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==", - "devOptional": true, "license": "MIT" }, "node_modules/better-sqlite3": { @@ -3678,7 +3787,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -3721,7 +3829,6 @@ "version": "3.3.4", "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", - "devOptional": true, "license": "MIT", "dependencies": { "chokidar": "^5.0.0", @@ -3907,7 +4014,6 @@ "version": "4.5.1", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", - "devOptional": true, "license": "MIT", "dependencies": { "@kurkle/color": "^0.3.0" @@ -3920,7 +4026,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "devOptional": true, "license": "MIT", "dependencies": { "readdirp": "^5.0.0" @@ -3985,7 +4090,6 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "devOptional": true, "license": "MIT" }, "node_modules/convert-source-map": { @@ -4167,7 +4271,6 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", - "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -4213,14 +4316,12 @@ "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "devOptional": true, "license": "MIT" }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=0.10" @@ -4239,7 +4340,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "devOptional": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -4281,7 +4381,6 @@ "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", - "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -4309,7 +4408,6 @@ "version": "3.20.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", - "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -4334,7 +4432,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=14" @@ -4367,7 +4464,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "devOptional": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -4611,7 +4707,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4797,7 +4892,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5056,7 +5150,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", - "devOptional": true, "license": "MIT" }, "node_modules/extend": { @@ -5069,7 +5162,6 @@ "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "devOptional": true, "funding": [ { "type": "individual", @@ -5092,7 +5184,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "devOptional": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -5143,7 +5234,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "devOptional": true, "funding": [ { "type": "github", @@ -5256,7 +5346,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "devOptional": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -5338,7 +5427,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", - "devOptional": true, "license": "MIT", "dependencies": { "is-property": "^1.0.2" @@ -5393,7 +5481,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", - "devOptional": true, "license": "MIT" }, "node_modules/get-proto": { @@ -5445,7 +5532,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.0.tgz", "integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==", - "devOptional": true, "license": "MIT", "bin": { "giget": "dist/cli.mjs" @@ -5517,21 +5603,18 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "devOptional": true, "license": "ISC" }, "node_modules/grammex": { "version": "3.1.12", "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", - "devOptional": true, "license": "MIT" }, "node_modules/graphmatch": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", - "devOptional": true, "license": "MIT" }, "node_modules/has-bigints": { @@ -5689,9 +5772,7 @@ "version": "4.12.27", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", - "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -5710,14 +5791,12 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", - "devOptional": true, "license": "MIT" }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -6190,7 +6269,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", - "devOptional": true, "license": "MIT" }, "node_modules/is-regex": { @@ -6373,7 +6451,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -6813,7 +6890,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/longest-streak": { @@ -6853,7 +6929,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", - "devOptional": true, "license": "MIT", "engines": { "bun": ">=1.0.0", @@ -7814,7 +7889,6 @@ "version": "3.15.3", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", - "devOptional": true, "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.1", @@ -7835,7 +7909,6 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", - "devOptional": true, "license": "MIT", "dependencies": { "lru.min": "^1.1.0" @@ -7845,9 +7918,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -7892,16 +7965,16 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.2.9", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.9.tgz", - "integrity": "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz", + "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==", "license": "MIT", "dependencies": { - "@next/env": "16.2.9", + "@next/env": "16.3.0", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", + "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "bin": { @@ -7911,15 +7984,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.9", - "@next/swc-darwin-x64": "16.2.9", - "@next/swc-linux-arm64-gnu": "16.2.9", - "@next/swc-linux-arm64-musl": "16.2.9", - "@next/swc-linux-x64-gnu": "16.2.9", - "@next/swc-linux-x64-musl": "16.2.9", - "@next/swc-win32-arm64-msvc": "16.2.9", - "@next/swc-win32-x64-msvc": "16.2.9", - "sharp": "^0.34.5" + "@next/swc-darwin-arm64": "16.3.0", + "@next/swc-darwin-x64": "16.3.0", + "@next/swc-linux-arm64-gnu": "16.3.0", + "@next/swc-linux-arm64-musl": "16.3.0", + "@next/swc-linux-x64-gnu": "16.3.0", + "@next/swc-linux-x64-musl": "16.3.0", + "@next/swc-win32-arm64-msvc": "16.3.0", + "@next/swc-win32-x64-msvc": "16.3.0", + "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -7945,9 +8018,9 @@ } }, "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -7964,9 +8037,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -8186,7 +8259,6 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "devOptional": true, "license": "MIT" }, "node_modules/once": { @@ -8334,14 +8406,12 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, "license": "MIT" }, "node_modules/perfect-debounce": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", - "devOptional": true, "license": "MIT" }, "node_modules/picocolors": { @@ -8367,7 +8437,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", - "devOptional": true, "license": "MIT", "dependencies": { "confbox": "^0.2.4", @@ -8418,7 +8487,6 @@ "version": "3.4.7", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", - "devOptional": true, "license": "Unlicense", "engines": { "node": ">=12" @@ -8469,10 +8537,8 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", - "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "7.8.0", "@prisma/dev": "0.24.3", @@ -8516,7 +8582,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", - "devOptional": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -8528,7 +8593,6 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "devOptional": true, "license": "ISC" }, "node_modules/property-information": { @@ -8565,7 +8629,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, "funding": [ { "type": "individual", @@ -8627,7 +8690,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", - "devOptional": true, "license": "MIT", "dependencies": { "defu": "^6.1.6", @@ -8639,7 +8701,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8649,7 +8710,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8709,7 +8769,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -8833,7 +8892,6 @@ "version": "2.33.4", "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", - "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/remeda" @@ -8843,7 +8901,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8897,7 +8954,6 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 4" @@ -9051,7 +9107,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true, "license": "MIT" }, "node_modules/scheduler": { @@ -9073,8 +9128,7 @@ "node_modules/seq-queue": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", - "devOptional": true + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" }, "node_modules/set-function-length": { "version": "1.2.2", @@ -9126,48 +9180,53 @@ } }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/sharp/node_modules/semver": { @@ -9291,7 +9350,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -9368,7 +9426,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -9392,7 +9449,6 @@ "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "devOptional": true, "license": "MIT" }, "node_modules/stop-iteration-iterator": { @@ -9743,7 +9799,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9957,7 +10012,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10198,7 +10252,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", - "devOptional": true, "license": "MIT", "peerDependencies": { "typescript": ">=5" @@ -10628,7 +10681,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", - "devOptional": true, "license": "MIT", "dependencies": { "grammex": "^3.1.11", @@ -10640,7 +10692,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index b7bc5b1..66dbfd0 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,11 @@ "build": "next build", "start": "next start", "lint": "eslint", - "test": "vitest run" + "test": "vitest run", + "benchmark:remediation": "vitest run --config vitest.benchmark.config.ts", + "db:preflight": "node scripts/migration-preflight.mjs", + "db:backup": "node scripts/backup-database.mjs", + "auth:reset": "node scripts/request-password-reset.mjs" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -20,7 +24,8 @@ "better-sqlite3": "^12.11.1", "iron-session": "^8.0.4", "jsonrepair": "^3.14.1", - "next": "16.2.9", + "next": "16.3.0", + "prisma": "^7.8.0", "react": "19.2.4", "react-dom": "19.2.4", "react-markdown": "^10.1.0", @@ -30,12 +35,12 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/better-sqlite3": "^9.6.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.9", - "prisma": "^7.8.0", "tailwindcss": "^4", "typescript": "^5", "vitest": "^4.1.10" diff --git a/prisma/migrations/20260807090000_add_material_groups/migration.sql b/prisma/migrations/20260807090000_add_material_groups/migration.sql new file mode 100644 index 0000000..2227e4f --- /dev/null +++ b/prisma/migrations/20260807090000_add_material_groups/migration.sql @@ -0,0 +1,68 @@ +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; + +-- CreateTable +CREATE TABLE "MaterialGroup" ( + "id" TEXT NOT NULL PRIMARY KEY, + "classId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "type" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "MaterialGroup_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- RedefineTables +CREATE TABLE "new_Deck" ( + "id" TEXT NOT NULL PRIMARY KEY, + "classId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "groupId" TEXT, + CONSTRAINT "Deck_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "Deck_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MaterialGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_Deck" ("classId", "createdAt", "description", "id", "name", "sortOrder") +SELECT "classId", "createdAt", "description", "id", "name", "sortOrder" FROM "Deck"; +DROP TABLE "Deck"; +ALTER TABLE "new_Deck" RENAME TO "Deck"; + +CREATE TABLE "new_QuizSet" ( + "id" TEXT NOT NULL PRIMARY KEY, + "classId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "groupId" TEXT, + CONSTRAINT "QuizSet_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "QuizSet_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MaterialGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_QuizSet" ("classId", "createdAt", "description", "id", "name", "sortOrder") +SELECT "classId", "createdAt", "description", "id", "name", "sortOrder" FROM "QuizSet"; +DROP TABLE "QuizSet"; +ALTER TABLE "new_QuizSet" RENAME TO "QuizSet"; + +CREATE TABLE "new_ShareLink" ( + "id" TEXT NOT NULL PRIMARY KEY, + "targetType" TEXT NOT NULL, + "deckId" TEXT, + "quizSetId" TEXT, + "groupId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "ShareLink_deckId_fkey" FOREIGN KEY ("deckId") REFERENCES "Deck" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ShareLink_quizSetId_fkey" FOREIGN KEY ("quizSetId") REFERENCES "QuizSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ShareLink_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MaterialGroup" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_ShareLink" ("createdAt", "deckId", "id", "quizSetId", "targetType") +SELECT "createdAt", "deckId", "id", "quizSetId", "targetType" FROM "ShareLink"; +DROP TABLE "ShareLink"; +ALTER TABLE "new_ShareLink" RENAME TO "ShareLink"; +CREATE UNIQUE INDEX "ShareLink_deckId_key" ON "ShareLink"("deckId"); +CREATE UNIQUE INDEX "ShareLink_quizSetId_key" ON "ShareLink"("quizSetId"); +CREATE UNIQUE INDEX "ShareLink_groupId_key" ON "ShareLink"("groupId"); + +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20260807091000_add_quiz_attempt_snapshot/migration.sql b/prisma/migrations/20260807091000_add_quiz_attempt_snapshot/migration.sql new file mode 100644 index 0000000..289d17a --- /dev/null +++ b/prisma/migrations/20260807091000_add_quiz_attempt_snapshot/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "QuizAttempt" ADD COLUMN "reviewSnapshotJson" TEXT; diff --git a/prisma/migrations/20260807092000_add_progress_revisions/migration.sql b/prisma/migrations/20260807092000_add_progress_revisions/migration.sql new file mode 100644 index 0000000..792da5c --- /dev/null +++ b/prisma/migrations/20260807092000_add_progress_revisions/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "StudyProgress" ADD COLUMN "sessionId" TEXT NOT NULL DEFAULT 'legacy'; +ALTER TABLE "StudyProgress" ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1e031d9..45d61ec 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -99,6 +99,8 @@ model StudyProgress { orderJson String // JSON array of card/question ids answersJson String? // in-progress quiz answers cardResultsJson String? // per-card grades for decks + sessionId String @default("legacy") + revision Int @default(0) updatedAt DateTime @updatedAt deck Deck? @relation(fields: [deckId], references: [id], onDelete: Cascade) @@ -109,13 +111,14 @@ model StudyProgress { } model QuizAttempt { - id String @id @default(uuid()) - quizSetId String - score Float - maxScore Int - answersJson String - isPartialRetake Boolean @default(false) - completedAt DateTime @default(now()) + id String @id @default(uuid()) + quizSetId String + score Float + maxScore Int + answersJson String + reviewSnapshotJson String? + isPartialRetake Boolean @default(false) + completedAt DateTime @default(now()) quizSet QuizSet @relation(fields: [quizSetId], references: [id], onDelete: Cascade) } diff --git a/scripts/backup-database.mjs b/scripts/backup-database.mjs new file mode 100644 index 0000000..5f0b8fb --- /dev/null +++ b/scripts/backup-database.mjs @@ -0,0 +1,42 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import Database from "better-sqlite3"; +import { databasePathFromUrl } from "./databasePath.mjs"; + +function argument(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +export async function backupDatabase(sourcePath, outputPath) { + const source = path.resolve(sourcePath); + const output = path.resolve(outputPath); + if (!existsSync(source)) throw new Error(`Source database does not exist: ${source}`); + if (existsSync(output)) throw new Error(`Backup destination already exists: ${output}`); + if (source === output) throw new Error("Backup destination must differ from source"); + + const database = new Database(source, { readonly: true, fileMustExist: true }); + try { + await database.backup(output); + } finally { + database.close(); + } + + const backup = new Database(output, { readonly: true, fileMustExist: true }); + try { + const integrity = backup.pragma("integrity_check", { simple: true }); + if (integrity !== "ok") throw new Error(`Backup integrity check failed: ${integrity}`); + } finally { + backup.close(); + } + return output; +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const source = argument("--source") ?? databasePathFromUrl(process.env.DATABASE_URL); + const output = argument("--output"); + if (!output) throw new Error("Usage: npm run db:backup -- --output "); + const completedPath = await backupDatabase(source, output); + console.info(`Verified SQLite backup created: ${completedPath}`); +} diff --git a/scripts/databasePath.mjs b/scripts/databasePath.mjs new file mode 100644 index 0000000..86b9564 --- /dev/null +++ b/scripts/databasePath.mjs @@ -0,0 +1,10 @@ +import path from "node:path"; + +export function databasePathFromUrl(databaseUrl, cwd = process.cwd()) { + if (!databaseUrl?.startsWith("file:")) { + throw new Error("DATABASE_URL must be a file: SQLite URL"); + } + const rawPath = databaseUrl.slice("file:".length).split("?")[0]; + if (!rawPath) throw new Error("DATABASE_URL does not contain a database path"); + return path.resolve(cwd, rawPath); +} diff --git a/scripts/migration-preflight.mjs b/scripts/migration-preflight.mjs new file mode 100644 index 0000000..a058181 --- /dev/null +++ b/scripts/migration-preflight.mjs @@ -0,0 +1,312 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import Database from "better-sqlite3"; +import { databasePathFromUrl } from "./databasePath.mjs"; + +export const GROUP_MIGRATION = "20260807090000_add_material_groups"; + +function tableExists(database, table) { + return Boolean( + database + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(table) + ); +} + +function hasColumn(database, table, column) { + if (!tableExists(database, table)) return false; + return database.pragma(`table_info(${table})`).some((item) => item.name === column); +} + +function hasForeignKey(database, table, from, target, onDelete) { + return database + .pragma(`foreign_key_list(${table})`) + .some( + (item) => + item.from === from && item.table === target && item.on_delete === onDelete + ); +} + +function hasUniqueIndex(database, table, indexName, column) { + const index = database + .pragma(`index_list(${table})`) + .find((item) => item.name === indexName && item.unique === 1); + if (!index) return false; + const columns = database.pragma(`index_info(${indexName})`); + return columns.length === 1 && columns[0].name === column; +} + +function schemaSnapshot(database) { + const tables = database + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> '_prisma_migrations' ORDER BY name" + ) + .all() + .map((row) => row.name); + + return tables.map((table) => ({ + table, + columns: database.pragma(`table_info(${JSON.stringify(table)})`).map((column) => ({ + name: column.name, + type: column.type, + notnull: column.notnull, + defaultValue: column.dflt_value, + primaryKey: column.pk, + })), + foreignKeys: database + .pragma(`foreign_key_list(${JSON.stringify(table)})`) + .map((foreignKey) => ({ + id: foreignKey.id, + sequence: foreignKey.seq, + target: foreignKey.table, + from: foreignKey.from, + to: foreignKey.to, + onUpdate: foreignKey.on_update, + onDelete: foreignKey.on_delete, + match: foreignKey.match, + })) + .sort((left, right) => left.id - right.id || left.sequence - right.sequence), + indexes: database + .pragma(`index_list(${JSON.stringify(table)})`) + .filter((index) => !index.name.startsWith("sqlite_autoindex_")) + .map((index) => ({ + name: index.name, + unique: index.unique, + partial: index.partial, + columns: database + .pragma(`index_info(${JSON.stringify(index.name)})`) + .map((column) => column.name), + })) + .sort((left, right) => left.name.localeCompare(right.name)), + })); +} + +function expectedSchemaSnapshot(migrationNames) { + const database = new Database(":memory:"); + try { + database.pragma("foreign_keys = ON"); + for (const migrationName of migrationNames) { + database.exec( + readFileSync( + path.join( + process.cwd(), + "prisma", + "migrations", + migrationName, + "migration.sql" + ), + "utf8" + ) + ); + } + return schemaSnapshot(database); + } finally { + database.close(); + } +} + +export function inspectMigrationState(databasePath) { + if (!existsSync(databasePath)) { + throw new Error(`Database does not exist: ${databasePath}`); + } + const database = new Database(databasePath, { readonly: true, fileMustExist: true }); + try { + const migrationNames = tableExists(database, "_prisma_migrations") + ? database + .prepare( + 'SELECT "migration_name" FROM "_prisma_migrations" WHERE "finished_at" IS NOT NULL AND "rolled_back_at" IS NULL' + ) + .all() + .map((row) => row.migration_name) + : []; + const committedMigrations = readdirSync( + path.join(process.cwd(), "prisma", "migrations"), + { withFileTypes: true } + ) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + const priorMigrations = committedMigrations.filter( + (name) => name < GROUP_MIGRATION + ); + const laterMigrations = committedMigrations.filter( + (name) => name > GROUP_MIGRATION + ); + + const artifacts = { + materialGroup: tableExists(database, "MaterialGroup"), + deckColumn: hasColumn(database, "Deck", "groupId"), + quizColumn: hasColumn(database, "QuizSet", "groupId"), + shareColumn: hasColumn(database, "ShareLink", "groupId"), + deckForeignKey: hasForeignKey( + database, + "Deck", + "groupId", + "MaterialGroup", + "SET NULL" + ), + quizForeignKey: hasForeignKey( + database, + "QuizSet", + "groupId", + "MaterialGroup", + "SET NULL" + ), + shareForeignKey: hasForeignKey( + database, + "ShareLink", + "groupId", + "MaterialGroup", + "CASCADE" + ), + shareUnique: hasUniqueIndex( + database, + "ShareLink", + "ShareLink_groupId_key", + "groupId" + ), + }; + const values = Object.values(artifacts); + const groupArtifactsPresent = values.every(Boolean); + const groupArtifactsAbsent = values.every((value) => !value); + const groupMigrationApplied = migrationNames.includes(GROUP_MIGRATION); + const priorHistoryComplete = priorMigrations.every((name) => + migrationNames.includes(name) + ); + const knownMigrationNames = new Set(committedMigrations); + const historyHasUnknown = migrationNames.some( + (name) => !knownMigrationNames.has(name) + ); + const laterHistoryPresent = laterMigrations.some((name) => + migrationNames.includes(name) + ); + const actualSchema = schemaSnapshot(database); + const matches = (expected) => + JSON.stringify(actualSchema) === JSON.stringify(expected); + const preGroupSchemaExact = matches(expectedSchemaSnapshot(priorMigrations)); + const adoptedGroupSchemaExact = matches( + expectedSchemaSnapshot([...priorMigrations, GROUP_MIGRATION]) + ); + const appliedPrefix = committedMigrations.filter((name, index) => + committedMigrations.slice(0, index + 1).every((candidate) => + migrationNames.includes(candidate) + ) + ); + const appliedHistoryExact = + !historyHasUnknown && + migrationNames.length === appliedPrefix.length && + migrationNames.every((name) => appliedPrefix.includes(name)); + const trackedSchemaExact = + appliedHistoryExact && matches(expectedSchemaSnapshot(appliedPrefix)); + + let classification = "CONFLICT"; + if (groupMigrationApplied && trackedSchemaExact) classification = "CURRENT"; + else if ( + !groupMigrationApplied && + !laterHistoryPresent && + priorHistoryComplete && + migrationNames.length === priorMigrations.length && + groupArtifactsAbsent && + preGroupSchemaExact + ) classification = "APPLY"; + else if ( + !groupMigrationApplied && + !laterHistoryPresent && + priorHistoryComplete && + migrationNames.length === priorMigrations.length && + groupArtifactsPresent && + adoptedGroupSchemaExact + ) classification = "ADOPT"; + + return { + classification, + artifacts, + migrationNames, + priorHistoryComplete, + exactSchema: { + preGroup: preGroupSchemaExact, + adoptedGroup: adoptedGroupSchemaExact, + tracked: trackedSchemaExact, + }, + }; + } finally { + database.close(); + } +} + +function verifyBackup(backupPath, sourcePath) { + if (!backupPath || !existsSync(backupPath)) { + throw new Error("--resolve requires an existing verified --backup file"); + } + if (path.resolve(backupPath) === path.resolve(sourcePath)) { + throw new Error("--backup must be a separate database file, not the source database"); + } + const backup = new Database(backupPath, { readonly: true, fileMustExist: true }); + const source = new Database(sourcePath, { readonly: true, fileMustExist: true }); + try { + if (backup.pragma("integrity_check", { simple: true }) !== "ok") { + throw new Error("Backup failed SQLite integrity_check"); + } + if (JSON.stringify(schemaSnapshot(backup)) !== JSON.stringify(schemaSnapshot(source))) { + throw new Error("Backup schema does not match the source database"); + } + const sourceCounts = Object.fromEntries( + schemaSnapshot(source).map(({ table }) => [ + table, + source.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count, + ]) + ); + const backupCounts = Object.fromEntries( + schemaSnapshot(backup).map(({ table }) => [ + table, + backup.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count, + ]) + ); + if (JSON.stringify(sourceCounts) !== JSON.stringify(backupCounts)) { + throw new Error("Backup row counts do not match the source database"); + } + } finally { + backup.close(); + source.close(); + } +} + +function resolveMigration(databaseUrl) { + const cli = path.join(process.cwd(), "node_modules", "prisma", "build", "index.js"); + const result = spawnSync( + process.execPath, + [cli, "migrate", "resolve", "--applied", GROUP_MIGRATION], + { + cwd: process.cwd(), + env: { ...process.env, DATABASE_URL: databaseUrl }, + encoding: "utf8", + } + ); + if (result.status !== 0) { + throw new Error(`Prisma migration resolve failed:\n${result.stdout}\n${result.stderr}`); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const databaseUrl = process.env.DATABASE_URL; + const databasePath = databasePathFromUrl(databaseUrl); + const state = inspectMigrationState(databasePath); + console.info(JSON.stringify(state, null, 2)); + + if (process.argv.includes("--resolve")) { + if (state.classification !== "ADOPT") { + throw new Error(`Refusing adoption for ${state.classification} database state`); + } + const backupIndex = process.argv.indexOf("--backup"); + verifyBackup( + backupIndex >= 0 ? process.argv[backupIndex + 1] : undefined, + databasePath + ); + resolveMigration(databaseUrl); + console.info(`Marked ${GROUP_MIGRATION} applied after exact-schema preflight.`); + } else if (state.classification === "CONFLICT") { + process.exitCode = 2; + } +} diff --git a/scripts/request-password-reset.mjs b/scripts/request-password-reset.mjs new file mode 100644 index 0000000..1847eb2 --- /dev/null +++ b/scripts/request-password-reset.mjs @@ -0,0 +1,56 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import Database from "better-sqlite3"; +import { databasePathFromUrl } from "./databasePath.mjs"; + +const PASSWORD_HASH_KEY = "admin_password_hash"; +const RESET_TOKEN_KEY = "admin_password_reset"; +const RESET_TOKEN_LIFETIME_MS = 15 * 60 * 1000; + +function activeRecord(value) { + try { + const record = JSON.parse(value); + return new Date(record.expiresAt).getTime() > Date.now(); + } catch { + return false; + } +} + +export function createResetToken(databasePath) { + if (!existsSync(databasePath)) throw new Error(`Database does not exist: ${databasePath}`); + const database = new Database(databasePath, { fileMustExist: true }); + try { + if (!database.prepare('SELECT 1 FROM "Setting" WHERE "key" = ?').get(PASSWORD_HASH_KEY)) { + throw new Error("Password recovery is unavailable before initial setup"); + } + const existing = database + .prepare('SELECT "value" FROM "Setting" WHERE "key" = ?') + .get(RESET_TOKEN_KEY); + if (existing && activeRecord(existing.value)) { + throw new Error("An unexpired reset token already exists; it was not replaced"); + } + + const token = randomBytes(24).toString("base64url"); + const expiresAt = new Date(Date.now() + RESET_TOKEN_LIFETIME_MS).toISOString(); + const value = JSON.stringify({ + digest: createHash("sha256").update(token, "utf8").digest("hex"), + nonce: randomUUID(), + expiresAt, + }); + database + .prepare( + 'INSERT INTO "Setting" ("key", "value") VALUES (?, ?) ON CONFLICT("key") DO UPDATE SET "value" = excluded."value"' + ) + .run(RESET_TOKEN_KEY, value); + return { token, expiresAt }; + } finally { + database.close(); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const result = createResetToken(databasePathFromUrl(process.env.DATABASE_URL)); + console.info(`Password reset token: ${result.token}`); + console.info(`Expires: ${result.expiresAt}`); +} diff --git a/scripts/verify-backup-restore.mjs b/scripts/verify-backup-restore.mjs new file mode 100644 index 0000000..b67c924 --- /dev/null +++ b/scripts/verify-backup-restore.mjs @@ -0,0 +1,197 @@ +import { randomUUID } from "node:crypto"; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, +} from "node:fs"; +import path from "node:path"; +import Database from "better-sqlite3"; +import { backupDatabase } from "./backup-database.mjs"; + +const root = path.join(process.cwd(), ".test-databases"); +const directory = path.join(root, `backup-restore-${randomUUID()}`); +if (!path.resolve(directory).startsWith(`${path.resolve(root)}${path.sep}`)) { + throw new Error("Unsafe disposable backup verification path"); +} +mkdirSync(directory, { recursive: true }); +const sourcePath = path.join(directory, "source.test.db"); +const backupPath = path.join(directory, "backup.test.db"); +const restoredPath = path.join(directory, "restored.test.db"); +let source; + +function rowCounts(database) { + const tables = database + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ) + .all() + .map((row) => row.name); + return Object.fromEntries( + tables.map((table) => [ + table, + database.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count, + ]) + ); +} + +try { + source = new Database(sourcePath); + source.pragma("foreign_keys = ON"); + source.pragma("journal_mode = WAL"); + source.pragma("wal_autocheckpoint = 0"); + const migrations = readdirSync(path.join(process.cwd(), "prisma", "migrations"), { + withFileTypes: true, + }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + for (const migration of migrations) { + source.exec( + readFileSync( + path.join(process.cwd(), "prisma", "migrations", migration, "migration.sql"), + "utf8" + ) + ); + } + source.transaction(() => { + source + .prepare('INSERT INTO "Class" ("id", "name", "slug", "sortOrder") VALUES (?, ?, ?, ?)') + .run("backup-class", "Representative content", "representative-content", 0); + source + .prepare( + 'INSERT INTO "MaterialGroup" ("id", "classId", "name", "type", "sortOrder") VALUES (?, ?, ?, ?, ?)' + ) + .run("backup-group", "backup-class", "Backup group", "DECK", 0); + source + .prepare( + 'INSERT INTO "Deck" ("id", "classId", "groupId", "name", "description", "sortOrder") VALUES (?, ?, ?, ?, ?, ?)' + ) + .run( + "backup-deck", + "backup-class", + "backup-group", + "Backup deck", + "Restore drill content", + 0 + ); + source + .prepare( + 'INSERT INTO "Flashcard" ("id", "deckId", "front", "back", "sortOrder") VALUES (?, ?, ?, ?, ?)' + ) + .run("backup-card", "backup-deck", "Representative front", "Representative back", 0); + source + .prepare( + 'INSERT INTO "QuizSet" ("id", "classId", "name", "description", "sortOrder") VALUES (?, ?, ?, ?, ?)' + ) + .run("backup-quiz", "backup-class", "Backup quiz", "Quiz relationship", 0); + source + .prepare( + 'INSERT INTO "Question" ("id", "quizSetId", "type", "prompt", "rationale", "category", "sortOrder") VALUES (?, ?, ?, ?, ?, ?, ?)' + ) + .run( + "backup-question", + "backup-quiz", + "MULTIPLE_CHOICE", + "Representative prompt", + "Representative rationale", + "backup", + 0 + ); + source + .prepare( + 'INSERT INTO "AnswerOption" ("id", "questionId", "text", "isCorrect", "sortOrder") VALUES (?, ?, ?, ?, ?)' + ) + .run("backup-option", "backup-question", "Correct option", 1, 0); + source + .prepare( + 'INSERT INTO "SpacedRepetitionSet" ("id", "classId", "name", "sortOrder", "updatedAt") VALUES (?, ?, ?, ?, ?)' + ) + .run("backup-srs", "backup-class", "Backup SRS", 0, Date.now()); + source + .prepare( + 'INSERT INTO "SpacedRepetitionSetDeck" ("setId", "deckId", "sortOrder") VALUES (?, ?, ?)' + ) + .run("backup-srs", "backup-deck", 0); + source + .prepare( + 'INSERT INTO "ShareLink" ("id", "targetType", "groupId") VALUES (?, ?, ?)' + ) + .run("backup-share", "GROUP", "backup-group"); + })(); + + const walPath = `${sourcePath}-wal`; + if (!existsSync(walPath) || statSync(walPath).size === 0) { + throw new Error("Backup drill did not create an active WAL database"); + } + const sourceWalBytes = statSync(walPath).size; + const sourceCounts = rowCounts(source); + await backupDatabase(sourcePath, backupPath); + copyFileSync(backupPath, restoredPath); + source.close(); + source = undefined; + + const restored = new Database(restoredPath, { readonly: true, fileMustExist: true }); + try { + const integrity = restored.pragma("integrity_check", { simple: true }); + const foreignKeyErrors = restored.pragma("foreign_key_check"); + const restoredCounts = rowCounts(restored); + const representative = restored + .prepare(` + SELECT c.name AS className, g.name AS groupName, d.description, + f.front, s.name AS setName, sl.targetType + FROM "Class" c + JOIN "MaterialGroup" g ON g.classId = c.id + JOIN "Deck" d ON d.groupId = g.id + JOIN "Flashcard" f ON f.deckId = d.id + JOIN "SpacedRepetitionSetDeck" sd ON sd.deckId = d.id + JOIN "SpacedRepetitionSet" s ON s.id = sd.setId + JOIN "ShareLink" sl ON sl.groupId = g.id + WHERE d.id = ? + `) + .get("backup-deck"); + const quizRelationship = restored + .prepare(` + SELECT q.name AS quizName, question.prompt, option.text, option.isCorrect + FROM "QuizSet" q + JOIN "Question" question ON question.quizSetId = q.id + JOIN "AnswerOption" option ON option.questionId = question.id + WHERE q.id = ? + `) + .get("backup-quiz"); + if ( + integrity !== "ok" || + foreignKeyErrors.length !== 0 || + JSON.stringify(restoredCounts) !== JSON.stringify(sourceCounts) || + representative?.description !== "Restore drill content" || + representative?.setName !== "Backup SRS" || + representative?.targetType !== "GROUP" || + quizRelationship?.isCorrect !== 1 + ) { + throw new Error("Restored database did not preserve integrity, counts, content, and relationships"); + } + console.info( + JSON.stringify( + { + method: "better-sqlite3 online backup while the WAL source remained open", + sourceWalBytes, + integrity, + foreignKeyErrors, + counts: restoredCounts, + representative, + quizRelationship, + }, + null, + 2 + ) + ); + } finally { + restored.close(); + } +} finally { + source?.close(); + rmSync(directory, { recursive: true, force: true }); +} diff --git a/scripts/verify-http-smoke.mjs b/scripts/verify-http-smoke.mjs new file mode 100644 index 0000000..ba76b12 --- /dev/null +++ b/scripts/verify-http-smoke.mjs @@ -0,0 +1,83 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import Database from "better-sqlite3"; + +const root = path.join(process.cwd(), ".test-databases"); +const directory = path.join(root, `http-smoke-${randomUUID()}`); +if (!path.resolve(directory).startsWith(`${path.resolve(root)}${path.sep}`)) { + throw new Error("Unsafe disposable HTTP smoke path"); +} +mkdirSync(directory, { recursive: true }); +const databasePath = path.join(directory, "http-smoke.test.db"); +const database = new Database(databasePath); +try { + database.pragma("foreign_keys = ON"); + for (const migration of readdirSync(path.join(process.cwd(), "prisma", "migrations"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort()) { + database.exec(readFileSync(path.join(process.cwd(), "prisma", "migrations", migration, "migration.sql"), "utf8")); + } +} finally { + database.close(); +} + +const port = 3789; +const server = spawn( + process.execPath, + [path.join(process.cwd(), "node_modules", "next", "dist", "bin", "next"), "start", "-p", String(port)], + { + cwd: process.cwd(), + env: { + ...process.env, + NODE_ENV: "production", + DATABASE_URL: `file:${databasePath.replaceAll("\\", "/")}`, + SESSION_SECRET: "http-smoke-secret-0123456789abcdef0123456789", + ALLOW_INITIAL_SETUP: "true", + }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + } +); +let output = ""; +server.stdout.on("data", (chunk) => { output += chunk.toString(); }); +server.stderr.on("data", (chunk) => { output += chunk.toString(); }); + +try { + let health; + for (let attempt = 0; attempt < 30; attempt += 1) { + try { + health = await fetch(`http://127.0.0.1:${port}/api/health`); + if (health.ok) break; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 500)); + } + if (!health?.ok) throw new Error(`Health check did not become ready.\n${output}`); + const login = await fetch(`http://127.0.0.1:${port}/login`); + const setup = await fetch(`http://127.0.0.1:${port}/api/auth/setup-status`); + const setupBody = await setup.json(); + const protectedDot = await fetch(`http://127.0.0.1:${port}/api/decks/file.json`, { redirect: "manual" }); + if (!login.ok || !setup.ok || setupBody.setupRequired !== true || setupBody.setupAllowed !== true || protectedDot.status !== 307) { + throw new Error("HTTP smoke responses did not match the production contract"); + } + console.info(JSON.stringify({ + health: health.status, + login: login.status, + setup: setupBody, + protectedDot: { status: protectedDot.status, location: protectedDot.headers.get("location") }, + }, null, 2)); + if (process.argv.includes("--stay")) { + console.info(`Browser smoke server ready at http://127.0.0.1:${port}/login`); + await new Promise(() => {}); + } +} finally { + server.kill(); + await new Promise((resolve) => { + if (server.exitCode !== null) return resolve(); + server.once("exit", resolve); + setTimeout(resolve, 2_000); + }); + rmSync(directory, { recursive: true, force: true }); +} diff --git a/src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx b/src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx index 726de4e..8743b87 100644 --- a/src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx +++ b/src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx @@ -25,6 +25,8 @@ interface DeckData { currentIndex: number; orderJson: string; cardResultsJson: string | null; + sessionId: string; + revision: number; updatedAt: string; }>; } @@ -42,19 +44,24 @@ export default function DeckStudyPage() { async function handleRestart() { if (!deck) return; - // Clear progress in database for both modes - await Promise.all([ - fetch("/api/progress", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SEQUENTIAL" }) - }).catch(() => {}), - fetch("/api/progress", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SHUFFLED" }) - }).catch(() => {}) - ]); + const responses = await Promise.all( + deck.progress.map((progress) => + fetch("/api/progress", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contentType: "DECK", + contentId: deck.id, + mode: progress.mode, + sessionId: progress.sessionId, + }), + }) + ) + ).catch(() => null); + if (!responses || responses.some((response) => !response.ok)) { + window.alert("The session could not be restarted. Your saved progress was kept."); + return; + } // Clear locally and force remount setDeck({ ...deck, progress: [] }); @@ -75,7 +82,8 @@ export default function DeckStudyPage() { }, [deckId, classSlug, router]); useEffect(() => { - fetchDeck(); + const timer = window.setTimeout(() => void fetchDeck(), 0); + return () => window.clearTimeout(timer); }, [fetchDeck]); if (loading || !deck) { diff --git a/src/app/(protected)/[classSlug]/flashcards/page.tsx b/src/app/(protected)/[classSlug]/flashcards/page.tsx index 5ff4316..d5ce426 100644 --- a/src/app/(protected)/[classSlug]/flashcards/page.tsx +++ b/src/app/(protected)/[classSlug]/flashcards/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { ImportModal } from "@/components/import/ImportModal"; @@ -13,7 +13,6 @@ import { useSensor, useSensors, DragEndEvent, - DragOverEvent, DragStartEvent, DragOverlay, useDroppable, @@ -41,6 +40,8 @@ interface DeckItem { currentIndex: number; orderJson: string; cardResultsJson: string | null; + sessionId: string; + revision: number; }>; } @@ -50,15 +51,25 @@ interface MaterialGroup { sortOrder: number; } -const cache: Record = {}; - function getProgressLabel(deck: DeckItem) { if (!deck.progress?.length) return null; const prog = deck.progress[0]; - const order = JSON.parse(prog.orderJson) as string[]; - const results = prog.cardResultsJson - ? (JSON.parse(prog.cardResultsJson) as Record) - : {}; + let order: string[]; + let results: Record; + try { + const parsedOrder: unknown = JSON.parse(prog.orderJson); + const parsedResults: unknown = prog.cardResultsJson + ? JSON.parse(prog.cardResultsJson) + : {}; + if (!Array.isArray(parsedOrder)) throw new Error("Invalid progress order"); + order = parsedOrder.filter((id): id is string => typeof id === "string"); + results = + typeof parsedResults === "object" && parsedResults !== null + ? (parsedResults as Record) + : {}; + } catch { + return "Saved session needs repair"; + } const correctCount = Object.values(results).filter((r) => r === "correct").length; const total = order.length; const current = Math.min(prog.currentIndex + 1, total); @@ -70,10 +81,21 @@ interface SortableDeckCardProps { deck: DeckItem; onEdit: (deck: DeckItem) => void; onDelete: (deck: DeckItem) => void; + groups: MaterialGroup[]; + onMove: (deckId: string, groupId: string | null) => void; + moveDisabled?: boolean; classSlug: string; } -function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCardProps) { +function SortableDeckCard({ + deck, + onEdit, + onDelete, + groups, + onMove, + moveDisabled = false, + classSlug, +}: SortableDeckCardProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id }); const style = { transform: CSS.Transform.toString(transform), @@ -117,10 +139,24 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCar + + )} + + {!loading && !loadError && decks.length === 0 && groups.length === 0 && (

No decks yet

Import your first deck or create a group

)} - {!loading && ( - + {!loading && !loadError && ( +
{groupedDecks.map(group => (
@@ -485,7 +622,7 @@ export default function FlashcardsPage() {
Drop decks here
) : ( group.decks.map(deck => ( - { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} /> + { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} groups={groups} onMove={handleMoveDeck} moveDisabled={isReordering} classSlug={classSlug} /> )) )} @@ -510,7 +647,7 @@ export default function FlashcardsPage() {
No uncategorized decks
) : ( uncategorizedDecks.map(deck => ( - { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} /> + { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} groups={groups} onMove={handleMoveDeck} moveDisabled={isReordering} classSlug={classSlug} /> )) )} @@ -522,7 +659,7 @@ export default function FlashcardsPage() { {activeDeck ? (
- {}} onDelete={()=>{}} classSlug={classSlug} /> + {}} onDelete={()=>{}} groups={groups} onMove={()=>{}} moveDisabled classSlug={classSlug} />
) : null}
diff --git a/src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx b/src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx index 9c57430..2055011 100644 --- a/src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx +++ b/src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx @@ -27,6 +27,7 @@ interface QuizAttempt { score: number; maxScore: number; answersJson: string; + reviewSnapshotJson?: string | null; isPartialRetake: boolean; completedAt: string; } @@ -42,6 +43,8 @@ interface QuizData { currentIndex: number; orderJson: string; answersJson: string | null; + sessionId: string; + revision: number; }>; } @@ -60,13 +63,23 @@ export default function QuizStudyPage() { async function handleRestart() { if (!quiz) return; - - // Clear progress in database - await fetch("/api/progress", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ contentType: "QUIZ", contentId: quiz.id, mode: "SEQUENTIAL" }) - }).catch(() => {}); + const progress = quiz.progress.find((item) => item.mode === "SEQUENTIAL"); + if (progress) { + const response = await fetch("/api/progress", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contentType: "QUIZ", + contentId: quiz.id, + mode: "SEQUENTIAL", + sessionId: progress.sessionId, + }), + }).catch(() => null); + if (!response?.ok) { + window.alert("The quiz could not be restarted. Your saved progress was kept."); + return; + } + } // Clear locally and force remount setQuiz({ ...quiz, progress: [] }); @@ -104,7 +117,8 @@ export default function QuizStudyPage() { }, [quizId, classSlug, router]); useEffect(() => { - fetchQuizAndAttempts(); + const timer = window.setTimeout(() => void fetchQuizAndAttempts(), 0); + return () => window.clearTimeout(timer); }, [fetchQuizAndAttempts]); const [showTopics, setShowTopics] = useState(false); @@ -221,6 +235,7 @@ export default function QuizStudyPage() { key={restartKey} quiz={quiz} retakeIds={retakeIds} + sessionKey={restartKey} onFinished={() => { router.push(`/${classSlug}/quizzes`); }} diff --git a/src/app/(protected)/[classSlug]/quizzes/page.tsx b/src/app/(protected)/[classSlug]/quizzes/page.tsx index 0509c3b..6ae9949 100644 --- a/src/app/(protected)/[classSlug]/quizzes/page.tsx +++ b/src/app/(protected)/[classSlug]/quizzes/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { ImportModal } from "@/components/import/ImportModal"; @@ -13,7 +13,6 @@ import { useSensor, useSensors, DragEndEvent, - DragOverEvent, DragStartEvent, DragOverlay, useDroppable, @@ -41,6 +40,8 @@ interface QuizItem { currentIndex: number; orderJson: string; answersJson: string | null; + sessionId: string; + revision: number; }>; } @@ -50,16 +51,25 @@ interface MaterialGroup { sortOrder: number; } -const cache: Record = {}; - interface SortableQuizCardProps { quiz: QuizItem; onEdit: (quiz: QuizItem) => void; onDelete: (quiz: QuizItem) => void; + groups: MaterialGroup[]; + onMove: (quizId: string, groupId: string | null) => void; + moveDisabled?: boolean; classSlug: string; } -function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCardProps) { +function SortableQuizCard({ + quiz, + onEdit, + onDelete, + groups, + onMove, + moveDisabled = false, + classSlug, +}: SortableQuizCardProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: quiz.id }); const style = { transform: CSS.Transform.toString(transform), @@ -103,11 +113,22 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCar
+ + +
+ )} + + {!loading && !loadError && quizzes.length === 0 && groups.length === 0 && (

No quizzes yet

Import your first quiz or create a group

)} - {!loading && ( - + {!loading && !loadError && ( +
{groupedQuizzes.map(group => (
@@ -476,7 +583,7 @@ export default function QuizzesPage() { )}
- +
@@ -489,7 +596,7 @@ export default function QuizzesPage() {
Drop quizzes here
) : ( group.quizzes.map(quiz => ( - { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} /> + { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} groups={groups} onMove={handleMoveQuiz} moveDisabled={isReordering} classSlug={classSlug} /> )) )} @@ -514,7 +621,7 @@ export default function QuizzesPage() {
No uncategorized quizzes
) : ( uncategorizedQuizzes.map(quiz => ( - { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} /> + { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} groups={groups} onMove={handleMoveQuiz} moveDisabled={isReordering} classSlug={classSlug} /> )) )} @@ -526,7 +633,7 @@ export default function QuizzesPage() { {activeQuiz ? (
- {}} onDelete={()=>{}} classSlug={classSlug} /> + {}} onDelete={()=>{}} groups={groups} onMove={()=>{}} moveDisabled classSlug={classSlug} />
) : null}
diff --git a/src/app/(protected)/page.tsx b/src/app/(protected)/page.tsx index 5d37387..d411e93 100644 --- a/src/app/(protected)/page.tsx +++ b/src/app/(protected)/page.tsx @@ -20,6 +20,7 @@ const accents = ["#4f46e5", "#f97360", "#25845f", "#c27718", "#8b5cf6", "#0284c7 export default function HomePage() { const [classes, setClasses] = useState([]); const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); const [showCreate, setShowCreate] = useState(false); const [newName, setNewName] = useState(""); const [creating, setCreating] = useState(false); @@ -27,13 +28,25 @@ export default function HomePage() { const [editName, setEditName] = useState(""); const [savingEdit, setSavingEdit] = useState(false); - useEffect(() => { fetchClasses(); }, []); + useEffect(() => { + const controller = new AbortController(); + void fetchClasses(controller.signal); + return () => controller.abort(); + }, []); - async function fetchClasses() { + async function fetchClasses(signal?: AbortSignal) { + setLoading(true); + setLoadError(null); try { - const res = await fetch("/api/classes"); + const res = await fetch("/api/classes", { signal }); + if (!res.ok) throw new Error("The server could not load your classes."); setClasses(await res.json()); - } finally { setLoading(false); } + } catch (error) { + if (signal?.aborted) return; + setLoadError(error instanceof Error ? error.message : "Unable to load classes."); + } finally { + if (!signal?.aborted) setLoading(false); + } } async function handleCreate(e: React.FormEvent) { @@ -42,14 +55,21 @@ export default function HomePage() { setCreating(true); try { const res = await fetch("/api/classes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName.trim() }) }); - if (res.ok) { setNewName(""); setShowCreate(false); fetchClasses(); } + if (!res.ok) throw new Error("The class could not be created."); + setNewName(""); setShowCreate(false); void fetchClasses(); + } catch (error) { + setLoadError(error instanceof Error ? error.message : "The class could not be created."); } finally { setCreating(false); } } async function handleDelete(id: string, name: string) { if (!confirm(`Delete "${name}" and all its decks and quizzes?`)) return; - await fetch(`/api/classes/${id}`, { method: "DELETE" }); - fetchClasses(); + const response = await fetch(`/api/classes/${id}`, { method: "DELETE" }).catch(() => null); + if (!response?.ok) { + setLoadError("The class could not be deleted. Retry when the server is available."); + return; + } + void fetchClasses(); } async function handleSaveEdit(id: string) { @@ -57,7 +77,10 @@ export default function HomePage() { setSavingEdit(true); try { const res = await fetch(`/api/classes/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editName.trim() }) }); - if (res.ok) { setEditingId(null); fetchClasses(); } + if (!res.ok) throw new Error("The class could not be renamed."); + setEditingId(null); void fetchClasses(); + } catch (error) { + setLoadError(error instanceof Error ? error.message : "The class could not be renamed."); } finally { setSavingEdit(false); } } @@ -93,7 +116,15 @@ export default function HomePage() {
)} - {!loading && classes.length === 0 && ( + {!loading && loadError && ( +
+

Your study spaces could not be loaded.

+

{loadError}

+ +
+ )} + + {!loading && !loadError && classes.length === 0 && (
+

Your desk is ready

@@ -102,7 +133,7 @@ export default function HomePage() {
)} - {!loading && classes.length > 0 && ( + {!loading && !loadError && classes.length > 0 && (
{classes.map((cls, index) => (
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index d609732..f557aaa 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -4,8 +4,7 @@ import { checkRateLimit } from "@/lib/rateLimiter"; import { login } from "@/services/authService"; export async function POST(request: NextRequest) { - const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; - if (!checkRateLimit(`login:${ip}`).allowed) { + if (!checkRateLimit("login:global").allowed) { return NextResponse.json( { error: "Too many requests. Try again shortly." }, { status: 429 } diff --git a/src/app/api/auth/password-reset/complete/route.ts b/src/app/api/auth/password-reset/complete/route.ts index e614085..8368cbe 100644 --- a/src/app/api/auth/password-reset/complete/route.ts +++ b/src/app/api/auth/password-reset/complete/route.ts @@ -1,8 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; import { completePasswordResetSchema } from "@/lib/validation/authSchemas"; +import { checkRateLimit } from "@/lib/rateLimiter"; import { completePasswordReset } from "@/services/authService"; export async function POST(request: NextRequest) { + if ( + !checkRateLimit("password-reset-complete:global", { + windowMs: 60_000, + maxRequests: 5, + }).allowed + ) { + return NextResponse.json( + { error: "Too many attempts. Try again shortly." }, + { status: 429 } + ); + } const parsed = completePasswordResetSchema.safeParse( await request.json().catch(() => null) ); diff --git a/src/app/api/auth/password-reset/request/route.ts b/src/app/api/auth/password-reset/request/route.ts index 5ac61d3..9cb71bf 100644 --- a/src/app/api/auth/password-reset/request/route.ts +++ b/src/app/api/auth/password-reset/request/route.ts @@ -1,11 +1,17 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { checkRateLimit } from "@/lib/rateLimiter"; import { requestPasswordReset } from "@/services/authService"; -export async function POST(request: NextRequest) { - const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; - const rateLimit = checkRateLimit(`password-reset-request:${ip}`, { - windowMs: 60_000, +export async function POST() { + if (process.env.NODE_ENV === "production") { + return NextResponse.json( + { error: "Start password recovery from the local server console." }, + { status: 403 } + ); + } + + const rateLimit = checkRateLimit("password-reset-request:global", { + windowMs: 15 * 60_000, maxRequests: 1, }); if (!rateLimit.allowed) { @@ -15,11 +21,18 @@ export async function POST(request: NextRequest) { ); } - if (!(await requestPasswordReset())) { + const result = await requestPasswordReset(); + if (result.status === "missing-password") { return NextResponse.json( { error: "Password recovery is unavailable before initial setup." }, { status: 409 } ); } - return NextResponse.json({ success: true }); + if (result.status === "active-token") { + return NextResponse.json( + { error: "An unexpired reset token already exists." }, + { status: 409 } + ); + } + return NextResponse.json({ success: true, token: result.token }); } diff --git a/src/app/api/auth/password-reset/verify/route.ts b/src/app/api/auth/password-reset/verify/route.ts index fc05690..231232e 100644 --- a/src/app/api/auth/password-reset/verify/route.ts +++ b/src/app/api/auth/password-reset/verify/route.ts @@ -4,8 +4,7 @@ import { resetTokenSchema } from "@/lib/validation/authSchemas"; import { verifyPasswordResetToken } from "@/services/authService"; export async function POST(request: NextRequest) { - const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; - if (!checkRateLimit(`password-reset-verify:${ip}`).allowed) { + if (!checkRateLimit("password-reset-verify:global").allowed) { return NextResponse.json( { error: "Too many attempts. Try again shortly." }, { status: 429 } diff --git a/src/app/api/auth/setup-status/route.ts b/src/app/api/auth/setup-status/route.ts index 24816e0..99ed0e9 100644 --- a/src/app/api/auth/setup-status/route.ts +++ b/src/app/api/auth/setup-status/route.ts @@ -1,13 +1,9 @@ import { NextResponse } from "next/server"; -import { prisma } from "@/lib/db"; +import { getSetupStatus } from "@/services/authService"; export async function GET() { try { - const setting = await prisma.setting.findUnique({ - where: { key: "admin_password_hash" }, - }); - - return NextResponse.json({ setupRequired: !setting }); + return NextResponse.json(await getSetupStatus()); } catch (error) { console.error("Failed to check setup status:", error); return NextResponse.json( diff --git a/src/app/api/cards/[id]/route.ts b/src/app/api/cards/[id]/route.ts index a0a5100..bc7e4f9 100644 --- a/src/app/api/cards/[id]/route.ts +++ b/src/app/api/cards/[id]/route.ts @@ -1,21 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import * as cardService from "@/services/cardService"; +import { cardUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas"; export async function PATCH( request: NextRequest, ctx: RouteContext<"/api/cards/[id]"> ) { const { id } = await ctx.params; - const body = await request.json().catch(() => null); - + const parsed = cardUpdateSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid card update", details: parsed.error.issues }, { status: 400 }); + } try { - const updated = await cardService.updateCard(id, { - front: body?.front?.trim(), - back: body?.back?.trim(), - }); - return NextResponse.json(updated); - } catch { - return NextResponse.json({ error: "Card not found" }, { status: 404 }); + return NextResponse.json(await cardService.updateCard(id, parsed.data)); + } catch (error) { + if (isPrismaError(error, "P2025")) { + return NextResponse.json({ error: "Card not found" }, { status: 404 }); + } + console.error("Failed to update card", error); + return NextResponse.json({ error: "Failed to update card" }, { status: 500 }); } } @@ -24,11 +27,14 @@ export async function DELETE( ctx: RouteContext<"/api/cards/[id]"> ) { const { id } = await ctx.params; - try { await cardService.deleteCard(id); return NextResponse.json({ success: true }); - } catch { - return NextResponse.json({ error: "Card not found" }, { status: 404 }); + } catch (error) { + if (isPrismaError(error, "P2025")) { + return NextResponse.json({ error: "Card not found" }, { status: 404 }); + } + console.error("Failed to delete card", error); + return NextResponse.json({ error: "Failed to delete card" }, { status: 500 }); } } diff --git a/src/app/api/classes/[id]/route.ts b/src/app/api/classes/[id]/route.ts index dc66b93..89aad57 100644 --- a/src/app/api/classes/[id]/route.ts +++ b/src/app/api/classes/[id]/route.ts @@ -1,24 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import * as classService from "@/services/classService"; +import { classUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas"; export async function PATCH( request: NextRequest, ctx: RouteContext<"/api/classes/[id]"> ) { const { id } = await ctx.params; - const body = await request.json().catch(() => null); - - if (!body || (!body.name && body.name !== "")) { - return NextResponse.json({ error: "Nothing to update" }, { status: 400 }); + const parsed = classUpdateSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: "A valid class name is required" }, { status: 400 }); } - try { - const updated = await classService.updateClass(id, { - name: body.name?.trim(), - }); - return NextResponse.json(updated); - } catch { - return NextResponse.json({ error: "Class not found" }, { status: 404 }); + return NextResponse.json(await classService.updateClass(id, parsed.data)); + } catch (error) { + if (isPrismaError(error, "P2025")) { + return NextResponse.json({ error: "Class not found" }, { status: 404 }); + } + console.error("Failed to update class", error); + return NextResponse.json({ error: "Failed to update class" }, { status: 500 }); } } @@ -27,11 +27,14 @@ export async function DELETE( ctx: RouteContext<"/api/classes/[id]"> ) { const { id } = await ctx.params; - try { await classService.deleteClass(id); return NextResponse.json({ success: true }); - } catch { - return NextResponse.json({ error: "Class not found" }, { status: 404 }); + } catch (error) { + if (isPrismaError(error, "P2025")) { + return NextResponse.json({ error: "Class not found" }, { status: 404 }); + } + console.error("Failed to delete class", error); + return NextResponse.json({ error: "Failed to delete class" }, { status: 500 }); } } diff --git a/src/app/api/classes/route.ts b/src/app/api/classes/route.ts index 9a5b335..2be6d0d 100644 --- a/src/app/api/classes/route.ts +++ b/src/app/api/classes/route.ts @@ -1,20 +1,23 @@ import { NextRequest, NextResponse } from "next/server"; import * as classService from "@/services/classService"; +import { classCreateSchema, isPrismaError } from "@/lib/validation/contentSchemas"; export async function GET() { - const classes = await classService.listClasses(); - return NextResponse.json(classes); + return NextResponse.json(await classService.listClasses()); } export async function POST(request: NextRequest) { - const body = await request.json().catch(() => null); - if (!body?.name || typeof body.name !== "string" || !body.name.trim()) { - return NextResponse.json( - { error: "Class name is required" }, - { status: 400 } - ); + const parsed = classCreateSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: "Class name is required and must be 160 characters or fewer" }, { status: 400 }); + } + try { + return NextResponse.json(await classService.createClass(parsed.data.name), { status: 201 }); + } catch (error) { + if (isPrismaError(error, "P2002")) { + return NextResponse.json({ error: "A class with that slug already exists" }, { status: 409 }); + } + console.error("Failed to create class", error); + return NextResponse.json({ error: "Failed to create class" }, { status: 500 }); } - - const newClass = await classService.createClass(body.name.trim()); - return NextResponse.json(newClass, { status: 201 }); } diff --git a/src/app/api/decks/[id]/cards/route.ts b/src/app/api/decks/[id]/cards/route.ts index 4aa5fea..7416c0b 100644 --- a/src/app/api/decks/[id]/cards/route.ts +++ b/src/app/api/decks/[id]/cards/route.ts @@ -1,29 +1,26 @@ import { NextRequest, NextResponse } from "next/server"; import * as cardService from "@/services/cardService"; +import { cardContentSchema, isPrismaError } from "@/lib/validation/contentSchemas"; export async function POST( request: NextRequest, ctx: RouteContext<"/api/decks/[id]/cards"> ) { const { id: deckId } = await ctx.params; - const body = await request.json().catch(() => null); - - if ( - !body?.front || - !body?.back || - typeof body.front !== "string" || - typeof body.back !== "string" - ) { + const parsed = cardContentSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { return NextResponse.json( - { error: "front and back are required" }, + { error: "Card front and back are required and must be within the content limit", details: parsed.error.issues }, { status: 400 } ); } - - const card = await cardService.createCard(deckId, { - front: body.front.trim(), - back: body.back.trim(), - }); - - return NextResponse.json(card, { status: 201 }); + try { + return NextResponse.json(await cardService.createCard(deckId, parsed.data), { status: 201 }); + } catch (error) { + if (isPrismaError(error, "P2003")) { + return NextResponse.json({ error: "Deck not found" }, { status: 404 }); + } + console.error("Failed to create card", error); + return NextResponse.json({ error: "Failed to create card" }, { status: 500 }); + } } diff --git a/src/app/api/decks/[id]/route.ts b/src/app/api/decks/[id]/route.ts index b415c00..7fdd14f 100644 --- a/src/app/api/decks/[id]/route.ts +++ b/src/app/api/decks/[id]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import * as deckService from "@/services/deckService"; +import { contentUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas"; export async function GET( _request: NextRequest, @@ -20,16 +21,21 @@ export async function PATCH( ctx: RouteContext<"/api/decks/[id]"> ) { const { id } = await ctx.params; - const body = await request.json().catch(() => null); + const parsed = contentUpdateSchema.safeParse(await request.json().catch(() => null)); + + if (!parsed.success) { + return NextResponse.json({ error: "Invalid deck update", details: parsed.error.issues }, { status: 400 }); + } try { - const updated = await deckService.updateDeck(id, { - name: body?.name?.trim(), - description: body?.description, - }); + const updated = await deckService.updateDeck(id, parsed.data); return NextResponse.json(updated); - } catch { - return NextResponse.json({ error: "Deck not found" }, { status: 404 }); + } catch (error) { + if (isPrismaError(error, "P2025")) { + return NextResponse.json({ error: "Deck not found" }, { status: 404 }); + } + console.error("Failed to update deck", error); + return NextResponse.json({ error: "Failed to update deck" }, { status: 500 }); } } @@ -42,7 +48,11 @@ export async function DELETE( try { await deckService.deleteDeck(id); return NextResponse.json({ success: true }); - } catch { - return NextResponse.json({ error: "Deck not found" }, { status: 404 }); + } catch (error) { + if (isPrismaError(error, "P2025")) { + return NextResponse.json({ error: "Deck not found" }, { status: 404 }); + } + console.error("Failed to delete deck", error); + return NextResponse.json({ error: "Failed to delete deck" }, { status: 500 }); } } diff --git a/src/app/api/decks/reorder/route.ts b/src/app/api/decks/reorder/route.ts index c7bcc43..9c090fe 100644 --- a/src/app/api/decks/reorder/route.ts +++ b/src/app/api/decks/reorder/route.ts @@ -1,32 +1,29 @@ import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/db"; import { reorderRequestSchema } from "@/lib/validation/reorderSchemas"; +import { + ReorderConflictError, + ReorderValidationError, + reorderContent, +} from "@/services/reorderService"; export async function PATCH(request: NextRequest) { + const parsed = reorderRequestSchema.safeParse( + await request.json().catch(() => null) + ); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid reorder input" }, { status: 400 }); + } try { - const parsed = reorderRequestSchema.safeParse(await request.json()); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid input" }, { status: 400 }); - } - - const { items } = parsed.data; - - // items should be an array of { id, sortOrder, groupId } - // Using a transaction to perform all updates - await prisma.$transaction( - items.map((item) => - prisma.deck.update({ - where: { id: item.id }, - data: { - sortOrder: item.sortOrder, - groupId: item.groupId, - }, - }) - ) - ); - + await reorderContent("DECK", parsed.data); return NextResponse.json({ success: true }); } catch (error) { + if (error instanceof ReorderValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + if (error instanceof ReorderConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + console.error("Failed to reorder decks", error); return NextResponse.json({ error: "Failed to reorder decks" }, { status: 500 }); } } diff --git a/src/app/api/decks/route.ts b/src/app/api/decks/route.ts index b33fdbf..dbc6c62 100644 --- a/src/app/api/decks/route.ts +++ b/src/app/api/decks/route.ts @@ -1,35 +1,40 @@ import { NextRequest, NextResponse } from "next/server"; import * as deckService from "@/services/deckService"; -import { flashcardImportSchema } from "@/lib/validation/importSchemas"; +import { deckImportRequestSchema } from "@/lib/validation/importSchemas"; +import { readLimitedJson, RequestTooLargeError } from "@/lib/limitedJson"; export async function POST(request: NextRequest) { - const body = await request.json().catch(() => null); - - if (!body) { - return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + let body: unknown; + try { + body = await readLimitedJson(request); + } catch (error) { + if (error instanceof RequestTooLargeError) { + return NextResponse.json({ error: error.message }, { status: 413 }); + } + throw error; } - const { classId, data, name, groupId } = body; - - if (!classId || typeof classId !== "string") { - return NextResponse.json({ error: "classId is required" }, { status: 400 }); - } - - // Server-side validation — never trust client - const parsed = flashcardImportSchema.safeParse(data); + const parsed = deckImportRequestSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( - { error: "Validation failed", details: parsed.error.issues }, + { error: "Invalid deck import", details: parsed.error.issues }, { status: 400 } ); } - const deck = await deckService.createDeckFromImport( - classId, - parsed.data, - name?.trim() || undefined, - groupId || null - ); - - return NextResponse.json(deck, { status: 201 }); + try { + const deck = await deckService.createDeckFromImport( + parsed.data.classId, + parsed.data.data, + parsed.data.name, + parsed.data.groupId ?? null + ); + return NextResponse.json(deck, { status: 201 }); + } catch (error) { + if (error instanceof deckService.DeckValidationError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + console.error("Failed to create deck", error); + return NextResponse.json({ error: "Failed to create deck" }, { status: 500 }); + } } diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..cf75f33 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { checkApplicationHealth } from "@/services/healthService"; + +export async function GET() { + try { + await checkApplicationHealth(); + return NextResponse.json({ status: "ok" }); + } catch (error) { + console.error("Health check failed: database schema is unavailable", error); + return NextResponse.json({ status: "unhealthy" }, { status: 503 }); + } +} diff --git a/src/app/api/material-groups/[id]/route.ts b/src/app/api/material-groups/[id]/route.ts index bbd03cf..4e74abd 100644 --- a/src/app/api/material-groups/[id]/route.ts +++ b/src/app/api/material-groups/[id]/route.ts @@ -1,42 +1,44 @@ import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/db"; -import type { Prisma } from "@/generated/prisma/client"; +import { materialGroupUpdateSchema } from "@/lib/validation/materialGroupSchemas"; +import { + deleteMaterialGroup, + renameMaterialGroup, +} from "@/services/materialGroupService"; export async function PATCH( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { + const { id } = await params; + const parsed = materialGroupUpdateSchema.safeParse( + await request.json().catch(() => null) + ); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid group update" }, { status: 400 }); + } try { - const { id } = await params; - const body = await request.json(); - const { name, sortOrder } = body; - - const updateData: Prisma.MaterialGroupUpdateInput = {}; - if (name !== undefined) updateData.name = name; - if (sortOrder !== undefined) updateData.sortOrder = sortOrder; - - const group = await prisma.materialGroup.update({ - where: { id }, - data: updateData, - }); - - return NextResponse.json(group); + return NextResponse.json(await renameMaterialGroup(id, parsed.data.name)); } catch (error) { - return NextResponse.json({ error: "Failed to update material group" }, { status: 500 }); + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "P2025" + ) { + return NextResponse.json({ error: "Group not found" }, { status: 404 }); + } + console.error("Failed to rename material group", error); + return NextResponse.json({ error: "Failed to rename group" }, { status: 500 }); } } export async function DELETE( - request: NextRequest, + _request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - try { - const { id } = await params; - await prisma.materialGroup.delete({ - where: { id }, - }); - return NextResponse.json({ success: true }); - } catch (error) { - return NextResponse.json({ error: "Failed to delete material group" }, { status: 500 }); + const { id } = await params; + if (!(await deleteMaterialGroup(id))) { + return NextResponse.json({ error: "Group not found" }, { status: 404 }); } + return NextResponse.json({ success: true }); } diff --git a/src/app/api/material-groups/route.ts b/src/app/api/material-groups/route.ts index 2485c6d..8164ace 100644 --- a/src/app/api/material-groups/route.ts +++ b/src/app/api/material-groups/route.ts @@ -1,57 +1,38 @@ import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/db"; -import type { Prisma } from "@/generated/prisma/client"; +import { + materialGroupCreateSchema, + materialGroupQuerySchema, +} from "@/lib/validation/materialGroupSchemas"; +import { + createMaterialGroup, + listMaterialGroups, +} from "@/services/materialGroupService"; export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const classId = searchParams.get("classId"); - const type = searchParams.get("type"); - - if (!classId) { - return NextResponse.json({ error: "classId is required" }, { status: 400 }); - } - - if (type && type !== "DECK" && type !== "QUIZ") { - return NextResponse.json({ error: "Invalid type" }, { status: 400 }); - } - - const whereClause: Prisma.MaterialGroupWhereInput = { classId }; - if (type) { - whereClause.type = type; - } - - try { - const groups = await prisma.materialGroup.findMany({ - where: whereClause, - orderBy: [{ sortOrder: "desc" }, { createdAt: "desc" }], - }); - return NextResponse.json(groups); - } catch (error) { - return NextResponse.json({ error: "Failed to fetch material groups" }, { status: 500 }); + const parsed = materialGroupQuerySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams) + ); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid group query" }, { status: 400 }); } + return NextResponse.json( + await listMaterialGroups(parsed.data.classId, parsed.data.type) + ); } export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const { classId, name, type } = body; - - if (!classId || !name || (type !== "DECK" && type !== "QUIZ")) { - return NextResponse.json({ error: "Invalid input" }, { status: 400 }); - } - - const maxOrder = await prisma.materialGroup.aggregate({ - where: { classId, type }, - _max: { sortOrder: true }, - }); - const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1; - - const group = await prisma.materialGroup.create({ - data: { classId, name, type, sortOrder }, - }); - - return NextResponse.json(group, { status: 201 }); - } catch (error) { - return NextResponse.json({ error: "Failed to create material group" }, { status: 500 }); + const parsed = materialGroupCreateSchema.safeParse( + await request.json().catch(() => null) + ); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0]?.message ?? "Invalid group" }, + { status: 400 } + ); } + const group = await createMaterialGroup(parsed.data); + if (!group) { + return NextResponse.json({ error: "Class not found" }, { status: 404 }); + } + return NextResponse.json(group, { status: 201 }); } diff --git a/src/app/api/progress/route.ts b/src/app/api/progress/route.ts index 7b072d6..1366efb 100644 --- a/src/app/api/progress/route.ts +++ b/src/app/api/progress/route.ts @@ -1,66 +1,64 @@ import { NextRequest, NextResponse } from "next/server"; -import * as progressService from "@/services/progressService"; +import { + progressDeleteSchema, + progressPatchSchema, + progressQuerySchema, +} from "@/lib/validation/progressSchemas"; +import { + ProgressConflictError, + ProgressNotFoundError, + ProgressValidationError, + clearProgress, + getProgress, + saveProgress, +} from "@/services/progressService"; export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const contentType = searchParams.get("contentType") as "DECK" | "QUIZ"; - const contentId = searchParams.get("contentId"); - const mode = searchParams.get("mode") as "SEQUENTIAL" | "SHUFFLED"; - - if (!contentType || !contentId || !mode) { - return NextResponse.json( - { error: "contentType, contentId, and mode are required" }, - { status: 400 } - ); - } - - const progress = await progressService.getProgress( - contentType, - contentId, - mode + const parsed = progressQuerySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams) ); - - return NextResponse.json(progress); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid progress query" }, { status: 400 }); + } + return NextResponse.json(await getProgress(parsed.data)); } export async function PATCH(request: NextRequest) { - const body = await request.json().catch(() => null); - - if (!body?.contentType || !body?.contentId || !body?.mode) { + const parsed = progressPatchSchema.safeParse( + await request.json().catch(() => null) + ); + if (!parsed.success) { return NextResponse.json( - { error: "contentType, contentId, and mode are required" }, + { error: parsed.error.issues[0]?.message ?? "Invalid progress" }, { status: 400 } ); } - - const progress = await progressService.upsertProgress({ - contentType: body.contentType, - contentId: body.contentId, - mode: body.mode, - currentIndex: body.currentIndex ?? 0, - orderJson: body.orderJson, - answersJson: body.answersJson, - cardResultsJson: body.cardResultsJson, - }); - - return NextResponse.json(progress); + try { + return NextResponse.json(await saveProgress(parsed.data)); + } catch (error) { + if (error instanceof ProgressNotFoundError) { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + if (error instanceof ProgressConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + if (error instanceof ProgressValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + console.error("Failed to save study progress", error); + return NextResponse.json({ error: "Failed to save progress" }, { status: 500 }); + } } export async function DELETE(request: NextRequest) { - const body = await request.json().catch(() => null); - - if (!body?.contentType || !body?.contentId || !body?.mode) { + const parsed = progressDeleteSchema.safeParse( + await request.json().catch(() => null) + ); + if (!parsed.success) { return NextResponse.json( - { error: "contentType, contentId, and mode are required" }, + { error: parsed.error.issues[0]?.message ?? "Invalid progress deletion" }, { status: 400 } ); } - - await progressService.clearProgress( - body.contentType, - body.contentId, - body.mode - ); - - return NextResponse.json({ success: true }); + return NextResponse.json({ cleared: await clearProgress(parsed.data) }); } diff --git a/src/app/api/quizzes/[id]/attempt/route.ts b/src/app/api/quizzes/[id]/attempt/route.ts index 1f13839..0b1f301 100644 --- a/src/app/api/quizzes/[id]/attempt/route.ts +++ b/src/app/api/quizzes/[id]/attempt/route.ts @@ -1,65 +1,40 @@ import { NextRequest, NextResponse } from "next/server"; -import { getQuizSetWithQuestions, createQuizAttempt, listQuizAttempts } from "@/services/quizService"; -import { scoreQuiz } from "@/lib/scoring"; +import { + QuizAttemptValidationError, + QuizNotFoundError, + listQuizAttempts, + submitQuizAttempt, +} from "@/services/quizService"; +import { quizAttemptSchema } from "@/lib/validation/attemptSchemas"; export async function POST( request: NextRequest, ctx: RouteContext<"/api/quizzes/[id]/attempt"> ) { const { id } = await ctx.params; - const body = await request.json().catch(() => null); - - if (!body || !body.answersJson) { + const parsed = quizAttemptSchema.safeParse( + await request.json().catch(() => null) + ); + if (!parsed.success) { return NextResponse.json( - { error: "answersJson is required" }, + { error: parsed.error.issues[0]?.message ?? "Invalid attempt" }, { status: 400 } ); } - - const quizSet = await getQuizSetWithQuestions(id); - if (!quizSet) { - return NextResponse.json({ error: "Quiz not found" }, { status: 404 }); - } - - // Parse answers - let answers: Record; try { - answers = JSON.parse(body.answersJson); - } catch { - return NextResponse.json({ error: "Invalid answers JSON" }, { status: 400 }); + return NextResponse.json(await submitQuizAttempt(id, parsed.data), { + status: 201, + }); + } catch (error) { + if (error instanceof QuizNotFoundError) { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + if (error instanceof QuizAttemptValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + console.error("Failed to submit quiz attempt", error); + return NextResponse.json({ error: "Failed to submit attempt" }, { status: 500 }); } - - // Determine which questions were included in this attempt - const isPartialRetake = body.isPartialRetake === true; - - // If partial retake, we only score the questions that actually had answers provided - // or that were explicitly passed in a questionIds array. - // For simplicity, we filter the quizSet questions down to what's in the answers object - // if it's a partial retake, though the client will only show those anyway. - let questionsToScore = quizSet.questions; - if (isPartialRetake) { - const answeredIds = Object.keys(answers); - questionsToScore = quizSet.questions.filter(q => answeredIds.includes(q.id)); - } - - // Format questions for the scoring utility - const formattedQuestions = questionsToScore.map((q) => ({ - id: q.id, - type: q.type as "MULTIPLE_CHOICE" | "SATA", - options: q.options.map((o) => ({ id: o.id, isCorrect: o.isCorrect })), - })); - - const { total, maxScore } = scoreQuiz(formattedQuestions, answers); - - const attempt = await createQuizAttempt({ - quizSetId: id, - score: total, - maxScore: maxScore, - answersJson: body.answersJson, - isPartialRetake, - }); - - return NextResponse.json(attempt, { status: 201 }); } export async function GET( diff --git a/src/app/api/quizzes/[id]/route.ts b/src/app/api/quizzes/[id]/route.ts index 070bb2c..f912d7d 100644 --- a/src/app/api/quizzes/[id]/route.ts +++ b/src/app/api/quizzes/[id]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import * as quizService from "@/services/quizService"; +import { contentUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas"; export async function GET( _request: NextRequest, @@ -20,16 +21,21 @@ export async function PATCH( ctx: RouteContext<"/api/quizzes/[id]"> ) { const { id } = await ctx.params; - const body = await request.json().catch(() => null); + const parsed = contentUpdateSchema.safeParse(await request.json().catch(() => null)); + + if (!parsed.success) { + return NextResponse.json({ error: "Invalid quiz update", details: parsed.error.issues }, { status: 400 }); + } try { - const updated = await quizService.updateQuizSet(id, { - name: body?.name?.trim(), - description: body?.description, - }); + const updated = await quizService.updateQuizSet(id, parsed.data); return NextResponse.json(updated); - } catch { - return NextResponse.json({ error: "Quiz not found" }, { status: 404 }); + } catch (error) { + if (isPrismaError(error, "P2025")) { + return NextResponse.json({ error: "Quiz not found" }, { status: 404 }); + } + console.error("Failed to update quiz", error); + return NextResponse.json({ error: "Failed to update quiz" }, { status: 500 }); } } @@ -42,7 +48,11 @@ export async function DELETE( try { await quizService.deleteQuizSet(id); return NextResponse.json({ success: true }); - } catch { - return NextResponse.json({ error: "Quiz not found" }, { status: 404 }); + } catch (error) { + if (isPrismaError(error, "P2025")) { + return NextResponse.json({ error: "Quiz not found" }, { status: 404 }); + } + console.error("Failed to delete quiz", error); + return NextResponse.json({ error: "Failed to delete quiz" }, { status: 500 }); } } diff --git a/src/app/api/quizzes/reorder/route.ts b/src/app/api/quizzes/reorder/route.ts index 7ad7087..a6c3e01 100644 --- a/src/app/api/quizzes/reorder/route.ts +++ b/src/app/api/quizzes/reorder/route.ts @@ -1,32 +1,29 @@ import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/db"; import { reorderRequestSchema } from "@/lib/validation/reorderSchemas"; +import { + ReorderConflictError, + ReorderValidationError, + reorderContent, +} from "@/services/reorderService"; export async function PATCH(request: NextRequest) { + const parsed = reorderRequestSchema.safeParse( + await request.json().catch(() => null) + ); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid reorder input" }, { status: 400 }); + } try { - const parsed = reorderRequestSchema.safeParse(await request.json()); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid input" }, { status: 400 }); - } - - const { items } = parsed.data; - - // items should be an array of { id, sortOrder, groupId } - // Using a transaction to perform all updates - await prisma.$transaction( - items.map((item) => - prisma.quizSet.update({ - where: { id: item.id }, - data: { - sortOrder: item.sortOrder, - groupId: item.groupId, - }, - }) - ) - ); - + await reorderContent("QUIZ", parsed.data); return NextResponse.json({ success: true }); } catch (error) { + if (error instanceof ReorderValidationError) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + if (error instanceof ReorderConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }); + } + console.error("Failed to reorder quizzes", error); return NextResponse.json({ error: "Failed to reorder quizzes" }, { status: 500 }); } } diff --git a/src/app/api/quizzes/route.ts b/src/app/api/quizzes/route.ts index c6f1564..b14c02f 100644 --- a/src/app/api/quizzes/route.ts +++ b/src/app/api/quizzes/route.ts @@ -1,35 +1,43 @@ import { NextRequest, NextResponse } from "next/server"; import * as quizService from "@/services/quizService"; -import { quizImportSchema } from "@/lib/validation/importSchemas"; +import { quizImportRequestSchema } from "@/lib/validation/importSchemas"; +import { readLimitedJson, RequestTooLargeError } from "@/lib/limitedJson"; export async function POST(request: NextRequest) { - const body = await request.json().catch(() => null); - - if (!body) { - return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + let body: unknown; + try { + body = await readLimitedJson(request); + } catch (error) { + if (error instanceof RequestTooLargeError) { + return NextResponse.json({ error: error.message }, { status: 413 }); + } + throw error; } - const { classId, data, name, groupId } = body; - - if (!classId || typeof classId !== "string") { - return NextResponse.json({ error: "classId is required" }, { status: 400 }); - } - - // Server-side validation - const parsed = quizImportSchema.safeParse(data); + const parsed = quizImportRequestSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( - { error: "Validation failed", details: parsed.error.issues }, + { + error: "Invalid quiz import. SATA questions need at least two correct options.", + details: parsed.error.issues, + }, { status: 400 } ); } - const quizSet = await quizService.createQuizSetFromImport( - classId, - parsed.data, - name?.trim() || undefined, - groupId || null - ); - - return NextResponse.json(quizSet, { status: 201 }); + try { + const quiz = await quizService.createQuizSetFromImport( + parsed.data.classId, + parsed.data.data, + parsed.data.name, + parsed.data.groupId ?? null + ); + return NextResponse.json(quiz, { status: 201 }); + } catch (error) { + if (error instanceof quizService.QuizContentValidationError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + console.error("Failed to create quiz", error); + return NextResponse.json({ error: "Failed to create quiz" }, { status: 500 }); + } } diff --git a/src/app/api/share/route.ts b/src/app/api/share/route.ts index f29f093..c6642e5 100644 --- a/src/app/api/share/route.ts +++ b/src/app/api/share/route.ts @@ -1,20 +1,21 @@ import { NextRequest, NextResponse } from "next/server"; -import { toggleShareLink, getShareLinkForContent, isContentSharedViaGroup } from "@/services/shareService"; +import { shareTargetSchema } from "@/lib/validation/shareSchemas"; +import { + ShareTargetNotFoundError, + getShareLinkForContent, + isContentSharedViaGroup, + toggleShareLink, +} from "@/services/shareService"; export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const targetType = searchParams.get("targetType") as "DECK" | "QUIZ" | "GROUP"; - const contentId = searchParams.get("contentId"); - - if (!targetType || !contentId) { - return NextResponse.json( - { error: "targetType and contentId are required" }, - { status: 400 } - ); + const parsed = shareTargetSchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams) + ); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid share target" }, { status: 400 }); } - + const { targetType, contentId } = parsed.data; const link = await getShareLinkForContent(targetType, contentId); - if (targetType === "DECK" || targetType === "QUIZ") { const groupShare = await isContentSharedViaGroup(targetType, contentId); if (groupShare) { @@ -25,20 +26,27 @@ export async function GET(request: NextRequest) { }); } } - - return NextResponse.json({ token: link?.id || null, isGroupShared: false }); + return NextResponse.json({ token: link?.id ?? null, isGroupShared: false }); } export async function POST(request: NextRequest) { - const body = await request.json().catch(() => null); - - if (!body?.targetType || !body?.contentId) { - return NextResponse.json( - { error: "targetType and contentId are required" }, - { status: 400 } - ); + const parsed = shareTargetSchema.safeParse( + await request.json().catch(() => null) + ); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid share target" }, { status: 400 }); + } + try { + const link = await toggleShareLink( + parsed.data.targetType, + parsed.data.contentId + ); + return NextResponse.json({ token: link?.id ?? null }); + } catch (error) { + if (error instanceof ShareTargetNotFoundError) { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + console.error("Failed to update share link", error); + return NextResponse.json({ error: "Failed to update sharing" }, { status: 500 }); } - - const link = await toggleShareLink(body.targetType as "DECK" | "QUIZ" | "GROUP", body.contentId); - return NextResponse.json({ token: link?.id || null }); } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 32ce56c..1dea071 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -5,14 +5,15 @@ import { ThemeToggle } from "@/components/ui/ThemeToggle"; type LoginStage = "login" | "token" | "password" | "success"; -async function postJson(path: string, body?: object) { +async function postJson>(path: string, body?: object): Promise { const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body ?? {}), }); - const data: { error?: string } = await response.json(); + const data = await response.json() as T & { error?: string }; if (!response.ok) throw new Error(data.error || "Request failed"); + return data; } export default function LoginPage() { @@ -23,12 +24,26 @@ export default function LoginPage() { const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [setupRequired, setSetupRequired] = useState(null); + const [setupAllowed, setSetupAllowed] = useState(false); + const [statusError, setStatusError] = useState(""); useEffect(() => { - fetch("/api/auth/setup-status") - .then((response) => response.json()) - .then((data) => setSetupRequired(data.setupRequired)) - .catch(() => setSetupRequired(false)); + const controller = new AbortController(); + fetch("/api/auth/setup-status", { signal: controller.signal }) + .then((response) => { + if (!response.ok) throw new Error("Setup status could not be loaded."); + return response.json(); + }) + .then((data) => { + setSetupRequired(Boolean(data.setupRequired)); + setSetupAllowed(Boolean(data.setupAllowed)); + }) + .catch((caught) => { + if (controller.signal.aborted) return; + setStatusError(caught instanceof Error ? caught.message : "Setup status could not be loaded."); + setSetupRequired(false); + }); + return () => controller.abort(); }, []); async function run(action: () => Promise) { @@ -53,8 +68,8 @@ export default function LoginPage() { function beginReset() { void run(async () => { - await postJson("/api/auth/password-reset/request"); - setToken(""); + const response = await postJson<{ token: string }>("/api/auth/password-reset/request"); + setToken(response.token); setStage("token"); }); } @@ -139,6 +154,12 @@ export default function LoginPage() {
{stage === "login" && (
+ {setupRequired && !setupAllowed && ( +
+ Initial setup is disabled. Provision ADMIN_PASSWORD_HASH or temporarily enable ALLOW_INITIAL_SETUP on the local server. +
+ )} + {statusError && } - + {setupRequired ? "Save Password & Login" : "Sign In"} {!setupRequired && ( diff --git a/src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx b/src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx index 5381446..62711f1 100644 --- a/src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx +++ b/src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx @@ -6,7 +6,7 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import type { SharedGroupData } from "@/types/study"; -export function SharedGroupViewer({ data }: { data: SharedGroupData }) { +export function SharedGroupViewer({ data, token }: { data: SharedGroupData; token: string }) { const pathname = usePathname(); const router = useRouter(); @@ -16,29 +16,34 @@ export function SharedGroupViewer({ data }: { data: SharedGroupData }) { const [progressMap, setProgressMap] = useState>({}); useEffect(() => { - if (typeof window === 'undefined') return; - - const newProgress: Record = {}; - items.forEach((item) => { - const key = data.type === "DECK" ? `flashcard_progress_${item.id}` : `quiz_progress_${item.id}`; - const saved = localStorage.getItem(key); - if (saved) { - try { - const parsed = JSON.parse(saved); - const total = data.type === "DECK" ? (parsed.order?.length || item.cards?.length || 0) : (parsed.order?.length || item.questions?.length || 0); - newProgress[item.id] = { - currentIndex: parsed.currentIndex || 0, - total, - }; - } catch(e) {} - } - }); - setProgressMap(newProgress); - }, [items, data.type]); + const timer = window.setTimeout(() => { + const newProgress: Record = {}; + items.forEach((item) => { + const key = data.type === "DECK" + ? `flashcard_progress_${token}_${item.id}` + : `quiz_progress_${token}_${item.id}`; + const saved = localStorage.getItem(key); + if (saved) { + try { + const parsed = JSON.parse(saved); + const total = data.type === "DECK" ? (parsed.order?.length || item.cards?.length || 0) : (parsed.order?.length || item.questions?.length || 0); + newProgress[item.id] = { + currentIndex: parsed.currentIndex || 0, + total, + }; + } catch {} + } + }); + setProgressMap(newProgress); + }, 0); + return () => window.clearTimeout(timer); + }, [items, data.type, token]); const handleRestart = (e: React.MouseEvent, itemId: string) => { e.preventDefault(); - const key = data.type === "DECK" ? `flashcard_progress_${itemId}` : `quiz_progress_${itemId}`; + const key = data.type === "DECK" + ? `flashcard_progress_${token}_${itemId}` + : `quiz_progress_${token}_${itemId}`; localStorage.removeItem(key); setProgressMap(prev => { const next = { ...prev }; diff --git a/src/app/shared/[classSlug]/[type]/[token]/SharedViewer.tsx b/src/app/shared/[classSlug]/[type]/[token]/SharedViewer.tsx index 5aa6c63..0c39134 100644 --- a/src/app/shared/[classSlug]/[type]/[token]/SharedViewer.tsx +++ b/src/app/shared/[classSlug]/[type]/[token]/SharedViewer.tsx @@ -12,24 +12,29 @@ interface SharedViewerProps { type: "flashcards" | "quizzes"; data: SharedStudyItem; groupMode?: boolean; + token: string; } -export function SharedViewer({ type, data, groupMode = false }: SharedViewerProps) { +export function SharedViewer({ type, data, groupMode = false, token }: SharedViewerProps) { const pathname = usePathname(); const [restartKey, setRestartKey] = useState(0); const [view, setView] = useState<"study" | "list">("study"); const [hasSavedSession, setHasSavedSession] = useState(false); useEffect(() => { - // Check if there's a saved session for this item - const key = type === "flashcards" ? `flashcard_progress_${data.id}` : `quiz_progress_${data.id}`; - if (localStorage.getItem(key)) { - setHasSavedSession(true); - } - }, [type, data.id, restartKey]); + const timer = window.setTimeout(() => { + const key = type === "flashcards" + ? `flashcard_progress_${token}_${data.id}` + : `quiz_progress_${token}_${data.id}`; + setHasSavedSession(Boolean(localStorage.getItem(key))); + }, 0); + return () => window.clearTimeout(timer); + }, [type, data.id, restartKey, token]); function handleRestart() { - const key = type === "flashcards" ? `flashcard_progress_${data.id}` : `quiz_progress_${data.id}`; + const key = type === "flashcards" + ? `flashcard_progress_${token}_${data.id}` + : `quiz_progress_${token}_${data.id}`; localStorage.removeItem(key); setHasSavedSession(false); setRestartKey(k => k + 1); @@ -141,6 +146,7 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp cards={data.cards ?? []} deckId={data.id} isShared={true} + storageNamespace={token} /> ) : ( @@ -156,6 +162,7 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp }} retakeIds={null} isShared={true} + storageNamespace={token} /> )}
diff --git a/src/app/shared/[classSlug]/[type]/[token]/page.tsx b/src/app/shared/[classSlug]/[type]/[token]/page.tsx index c3ec969..e62a7e9 100644 --- a/src/app/shared/[classSlug]/[type]/[token]/page.tsx +++ b/src/app/shared/[classSlug]/[type]/[token]/page.tsx @@ -29,7 +29,7 @@ export async function generateMetadata( } const link = await getShareLinkMeta(token); - return buildShareMetadata(link, { classSlug, itemId }); + return buildShareMetadata(link, { classSlug, itemId, pathType: type }); } export default async function SharedPage(props: SharedPageProps & { searchParams: Promise<{ itemId?: string }> }) { @@ -71,18 +71,29 @@ export default async function SharedPage(props: SharedPageProps & { searchParams let targetType: SharedContentType = type === "flashcards" ? "flashcards" : "quizzes"; if (type === "groups" && link.group) { + const groupClassSlug = link.group.class.slug; + const validDecks = link.group.decks.filter( + (deck) => link.group?.type === "DECK" && deck.class.slug === groupClassSlug + ); + const validQuizSets = link.group.quizSets.filter( + (quiz) => link.group?.type === "QUIZ" && quiz.class.slug === groupClassSlug + ); if (itemId) { // Find the specific item inside the group if (link.group.type === "DECK") { - targetData = link.group.decks.find(d => d.id === itemId) ?? null; + targetData = validDecks.find(d => d.id === itemId) ?? null; targetType = "flashcards"; } else { - targetData = link.group.quizSets.find(q => q.id === itemId) ?? null; + targetData = validQuizSets.find(q => q.id === itemId) ?? null; targetType = "quizzes"; } if (!targetData) notFound(); } else { - targetData = link.group; + targetData = { + ...link.group, + decks: validDecks, + quizSets: validQuizSets, + }; } } else { targetData = type === "flashcards" ? link.deck : link.quizSet; @@ -126,12 +137,13 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
{type === "groups" && !itemId ? ( - + ) : ( )}
diff --git a/src/components/flashcards/CardManager.tsx b/src/components/flashcards/CardManager.tsx index f98bcb7..b372669 100644 --- a/src/components/flashcards/CardManager.tsx +++ b/src/components/flashcards/CardManager.tsx @@ -41,6 +41,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps) body: JSON.stringify({ front: editFront, back: editBack }), }); setEditingId(null); + window.dispatchEvent(new Event("study-decks-changed")); onCardsChanged(); } finally { setSaving(false); @@ -50,6 +51,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps) async function deleteCard(id: string) { if (!confirm("Delete this card?")) return; await fetch(`/api/cards/${id}`, { method: "DELETE" }); + window.dispatchEvent(new Event("study-decks-changed")); onCardsChanged(); } @@ -65,6 +67,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps) setNewFront(""); setNewBack(""); setShowAdd(false); + window.dispatchEvent(new Event("study-decks-changed")); onCardsChanged(); } finally { setSaving(false); @@ -89,7 +92,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
{/* Card list */} - {cards.map((card, index) => ( + {cards.map((card) => (
card.id), + currentIndex: 0, + data: {} as Record, + completed: cards.length === 0, + wasRecovered: false, + }; + } + return normalizeProgress({ + orderJson: progress.orderJson, + currentIndex: progress.currentIndex, + dataJson: progress.cardResultsJson, + liveIds: cards.map((card) => card.id), + isValidValue: (value): value is CardResult => + value === "correct" || value === "missed", + }); +} + +export function FlashcardViewer({ + cards, + deckId, + isShared = false, + storageNamespace, + initialProgress, +}: FlashcardViewerProps) { + const sharedStorageKey = `flashcard_progress_${storageNamespace ?? "shared"}_${deckId}`; + const [initialState] = useState(() => initialFlashcardState(cards, initialProgress)); const [isLoaded, setIsLoaded] = useState(!isShared); - const [order, setOrder] = useState( - initialProgress ? JSON.parse(initialProgress.orderJson) : cards.map((c) => c.id) - ); - const [currentIndex, setCurrentIndex] = useState( - initialProgress ? initialProgress.currentIndex : 0 - ); + const [order, setOrder] = useState(initialState.order); + const [currentIndex, setCurrentIndex] = useState(initialState.currentIndex); const [isFlipped, setIsFlipped] = useState(false); const [hasFlippedOnce, setHasFlippedOnce] = useState(false); - const [results, setResults] = useState>( - initialProgress && initialProgress.cardResultsJson - ? JSON.parse(initialProgress.cardResultsJson) - : {} - ); + const [results, setResults] = useState>(initialState.data); const [isShuffled, setIsShuffled] = useState( initialProgress ? initialProgress.mode === "SHUFFLED" : false ); const [toastMessage, setToastMessage] = useState(null); + const [restoreWarning, setRestoreWarning] = useState( + initialState.wasRecovered + ? "Some saved progress was invalid or referenced deleted cards, so it was safely repaired." + : null + ); + const [isTransitioning, setIsTransitioning] = useState(false); // If progress is provided and index is already at or past the end, it means completed - const [completed, setCompleted] = useState( - initialProgress - ? initialProgress.currentIndex >= JSON.parse(initialProgress.orderJson).length - : false - ); + const [completed, setCompleted] = useState(initialState.completed); const [swipeClass, setSwipeClass] = useState(""); const cardRef = useRef(null); const gradingRef = useRef(false); + const transitionTimerRef = useRef | null>(null); + const sessionIdRef = useRef( + initialProgress?.sessionId ?? globalThis.crypto.randomUUID() + ); + const revisionRef = useRef(initialProgress?.revision ?? 0); + const saveQueueRef = useRef(Promise.resolve()); // Touch/drag state const dragRef = useRef({ startX: 0, currentX: 0, isDragging: false }); @@ -73,7 +105,6 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre const currentCard = order.length > 0 ? cards.find((c) => c.id === order[currentIndex]) : null; const correctCount = Object.values(results).filter((r) => r === "correct").length; const missedCount = Object.values(results).filter((r) => r === "missed").length; - const totalGraded = correctCount + missedCount; // Current run of consecutive correct cards, derived by walking back from the // current position. Deriving it (rather than storing it) keeps resumed @@ -127,29 +158,104 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre // Load from localStorage if shared useEffect(() => { - if (isShared) { + if (!isShared) return; + const timer = window.setTimeout(() => { try { - const saved = localStorage.getItem(`flashcard_progress_${deckId}`); + const saved = localStorage.getItem(sharedStorageKey); if (saved) { - const parsed = JSON.parse(saved); - setOrder(parsed.order || cards.map(c => c.id)); - setCurrentIndex(parsed.currentIndex || 0); - setResults(parsed.results || {}); - setIsShuffled(parsed.mode === "SHUFFLED"); - - if (parsed.currentIndex >= (parsed.order?.length || cards.length)) { - setCompleted(true); + const parsed: unknown = JSON.parse(saved); + const record = + typeof parsed === "object" && parsed !== null + ? (parsed as Record) + : {}; + const restored = normalizeProgress({ + orderJson: record.order, + currentIndex: record.currentIndex, + dataJson: record.results, + liveIds: cards.map((card) => card.id), + isValidValue: (value): value is CardResult => + value === "correct" || value === "missed", + }); + setOrder(restored.order); + setCurrentIndex(restored.currentIndex); + setResults(restored.data); + setIsShuffled(record.mode === "SHUFFLED"); + setCompleted(restored.completed); + if (restored.wasRecovered) { + setRestoreWarning( + "Some saved progress was invalid or referenced deleted cards, so it was safely repaired." + ); } setToastMessage("Session restored"); } - } catch (e) { - // Fallback to defaults + } catch { + setRestoreWarning( + "Saved progress could not be read. A fresh session was started safely." + ); } setIsLoaded(true); - } - }, [isShared, deckId, cards]); + }, 0); + return () => window.clearTimeout(timer); + }, [isShared, sharedStorageKey, cards]); + + useEffect(() => { + return () => { + if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current); + }; + }, []); + + const saveProgress = useCallback( + ( + orderArr: string[], + index: number, + cardResults: Record, + mode: "SEQUENTIAL" | "SHUFFLED" + ) => { + if (isShared) { + localStorage.setItem( + sharedStorageKey, + JSON.stringify({ + mode, + currentIndex: index, + order: orderArr, + results: cardResults, + }) + ); + return; + } + + const revision = ++revisionRef.current; + saveQueueRef.current = saveQueueRef.current + .then(async () => { + const response = await fetch("/api/progress", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contentType: "DECK", + contentId: deckId, + mode, + currentIndex: index, + order: orderArr, + cardResults, + sessionId: sessionIdRef.current, + revision, + }), + }); + if (response.status === 409) { + setToastMessage("Progress changed in another session; reload to continue safely."); + return; + } + if (!response.ok) throw new Error("Progress save failed"); + }) + .catch(() => { + setToastMessage("Progress could not be saved. Your current view is unchanged."); + }); + }, + [deckId, isShared, sharedStorageKey] + ); function toggleShuffle() { + if (isTransitioning) return; const newShuffled = !isShuffled; setIsShuffled(newShuffled); @@ -175,6 +281,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre (grade: CardResult) => { if (!currentCard || !hasFlippedOnce || gradingRef.current) return; gradingRef.current = true; + setIsTransitioning(true); if (!isShared) { recordStudyActivity("FLASHCARD").catch(() => {}); @@ -187,15 +294,17 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre // Animate setSwipeClass(grade === "correct" ? "animate-swipe-right" : "animate-swipe-left"); - setTimeout(() => { + transitionTimerRef.current = setTimeout(() => { gradingRef.current = false; + setIsTransitioning(false); setSwipeClass(""); setIsFlipped(false); setHasFlippedOnce(false); if (currentIndex + 1 >= order.length) { setCompleted(true); - saveProgress(order, currentIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL"); + setCurrentIndex(order.length); + saveProgress(order, order.length, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL"); } else { const nextIndex = currentIndex + 1; setCurrentIndex(nextIndex); @@ -203,13 +312,13 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre } }, 350); }, - [currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled, isShared] + [currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled, isShared, saveProgress] ); // Keyboard shortcuts useEffect(() => { function handleKeyDown(e: KeyboardEvent) { - if (completed) return; + if (completed || isTransitioning) return; if (e.key === " " || e.key === "Enter" || e.key === "ArrowUp" || e.key === "ArrowDown") { e.preventDefault(); @@ -227,7 +336,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [completed, isFlipped, gradeCard]); + }, [completed, isFlipped, gradeCard, hasFlippedOnce, isTransitioning]); // Touch handlers for swipe function handleTouchStart(e: React.TouchEvent) { @@ -262,39 +371,9 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre } } - // Save progress (debounced / fire-and-forget) - function saveProgress( - orderArr: string[], - index: number, - cardResults: Record, - m: "SEQUENTIAL" | "SHUFFLED" - ) { - if (isShared) { - localStorage.setItem(`flashcard_progress_${deckId}`, JSON.stringify({ - mode: m, - currentIndex: index, - order: orderArr, - results: cardResults, - })); - return; - } - - fetch("/api/progress", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - contentType: "DECK", - contentId: deckId, - mode: m, - currentIndex: index, - orderJson: JSON.stringify(orderArr), - cardResultsJson: JSON.stringify(cardResults), - }), - }).catch(() => {}); // fire-and-forget - } - // Restart handlers function restartFullSet() { + if (isTransitioning) return; gradingRef.current = false; let newOrder: string[]; if (isShuffled) { @@ -312,6 +391,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre } function redoMissed() { + if (isTransitioning) return; const missedIds = Object.entries(results) .filter(([, r]) => r === "missed") .map(([id]) => id); @@ -337,6 +417,11 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre if (completed) { return (
+ {restoreWarning && ( +
+ {restoreWarning} +
+ )}
@@ -393,6 +478,11 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre // Study view return (
+ {restoreWarning && ( +
+ {restoreWarning} +
+ )} {/* Running tally */}
@@ -421,6 +511,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre ref={cardRef} className={`perspective-1000 cursor-pointer select-none relative ${swipeClass}`} onClick={() => { + if (isTransitioning) return; setToastMessage(null); setIsFlipped(!isFlipped); if (!isFlipped) setHasFlippedOnce(true); @@ -506,7 +597,8 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
+
+ ); + } + return (
+ {loadError &&

{loadError}

}

Copy these instructions and paste them into an LLM chat along with your study material. The LLM will generate JSON you can paste into the Import tab.

diff --git a/src/components/import/ImportTab.tsx b/src/components/import/ImportTab.tsx index 13c3b56..81b4c3b 100644 --- a/src/components/import/ImportTab.tsx +++ b/src/components/import/ImportTab.tsx @@ -134,6 +134,9 @@ export function ImportTab({ classId, importType, onImported }: ImportTabProps) { return; } + if (importType === "flashcards") { + window.dispatchEvent(new Event("study-decks-changed")); + } onImported(); } catch { setError("Network error during import"); diff --git a/src/components/quizzes/QuizResults.tsx b/src/components/quizzes/QuizResults.tsx index 95f0baa..31b5ce7 100644 --- a/src/components/quizzes/QuizResults.tsx +++ b/src/components/quizzes/QuizResults.tsx @@ -5,6 +5,7 @@ import remarkGfm from "remark-gfm"; import { CategoryBreakdown } from "./CategoryBreakdown"; import { scoreQuestion } from "@/lib/scoring"; import type { QuizAttempt, QuizQuestion, QuizSummary } from "@/types/study"; +import { parseQuizReviewSnapshot } from "@/lib/quizSnapshots"; interface QuizResultsProps { quiz: QuizSummary; @@ -20,17 +21,28 @@ interface ReviewItem { } export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsProps) { + const snapshot = parseQuizReviewSnapshot(attempt.reviewSnapshotJson); let answers: Record = {}; try { answers = JSON.parse(attempt.answersJson); } catch {} + if (snapshot) { + answers = Object.fromEntries( + snapshot.questions.map((question) => [question.id, question.selections]) + ); + } + // Determine non-full-credit questions for review list const reviewItems: ReviewItem[] = []; const missedIds: string[] = []; const answeredIds = Object.keys(answers); - const questionsToReview = quiz.questions.filter((q) => answeredIds.includes(q.id)); + const questionsToReview = snapshot?.questions ?? + quiz.questions.filter((q) => answeredIds.includes(q.id)); + const snapshotPoints = new Map( + snapshot?.questions.map((question) => [question.id, question.points]) ?? [] + ); questionsToReview.forEach((q) => { const formattedQ = { @@ -38,7 +50,8 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro type: q.type as "MULTIPLE_CHOICE" | "SATA", options: q.options, }; - const score = scoreQuestion(formattedQ, answers[q.id] || []); + const score = snapshotPoints.get(q.id) ?? + scoreQuestion(formattedQ, answers[q.id] || []); // If not full credit (1.0), add to review and missed arrays if (score < 1) { @@ -50,6 +63,9 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro }); } }); + const liveQuestionIds = new Set(quiz.questions.map((question) => question.id)); + const retakeableMissedIds = missedIds.filter((id) => liveQuestionIds.has(id)); + const reviewQuiz = { ...quiz, questions: questionsToReview }; const percentage = attempt.maxScore > 0 ? (attempt.score / attempt.maxScore) * 100 : 0; @@ -88,12 +104,12 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro )} - {missedIds.length > 0 && ( + {retakeableMissedIds.length > 0 && ( )} @@ -112,7 +128,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro {/* Category Breakdown */}

Category Breakdown

- +
{/* Review List */} diff --git a/src/components/quizzes/QuizViewer.tsx b/src/components/quizzes/QuizViewer.tsx index 204701e..94cbaf0 100644 --- a/src/components/quizzes/QuizViewer.tsx +++ b/src/components/quizzes/QuizViewer.tsx @@ -7,6 +7,7 @@ import { scoreQuestion } from "@/lib/scoring"; import { QuizResults } from "./QuizResults"; import { recordStudyActivity } from "@/lib/activityClient"; import type { QuizAttempt } from "@/types/study"; +import { normalizeProgress } from "@/lib/progressNormalization"; interface Option { id: string; @@ -33,16 +34,39 @@ interface QuizViewerProps { currentIndex: number; orderJson: string; answersJson: string | null; + sessionId: string; + revision: number; }>; }; retakeIds: string[] | null; + sessionKey?: string | number; + storageNamespace?: string; isShared?: boolean; onFinished?: () => void; } -export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: QuizViewerProps) { +export function QuizViewer({ + quiz, + retakeIds, + sessionKey = "default", + storageNamespace, + isShared = false, + onFinished, +}: QuizViewerProps) { const submittingQuestionRef = useRef(null); + const finishingRef = useRef(false); + const [initialQuiz] = useState(() => quiz); + const [initialRetakeIds] = useState(() => retakeIds); + const [initialProgress] = useState(() => + quiz.progress?.find((progress) => progress.mode === "SEQUENTIAL") + ); + const progressSessionIdRef = useRef( + initialProgress?.sessionId ?? globalThis.crypto.randomUUID() + ); + const progressRevisionRef = useRef(initialProgress?.revision ?? 0); + const saveQueueRef = useRef(Promise.resolve()); const [order, setOrder] = useState([]); + const [attemptScope, setAttemptScope] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); const [answers, setAnswers] = useState>({}); const [submittedAnswers, setSubmittedAnswers] = useState([]); @@ -51,6 +75,11 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu const [loading, setLoading] = useState(true); const [resultsData, setResultsData] = useState(null); const [toastMessage, setToastMessage] = useState(null); + const [isFinishing, setIsFinishing] = useState(false); + const [finishError, setFinishError] = useState(null); + const [restoreWarning, setRestoreWarning] = useState(null); + const sessionIdentity = `${quiz.id}:${sessionKey}`; + const sharedStorageKey = `quiz_progress_${storageNamespace ?? "shared"}_${quiz.id}`; // Auto-hide toast useEffect(() => { @@ -62,16 +91,18 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu // Initialize session useEffect(() => { + const timer = window.setTimeout(() => { // Generate shuffled options once per session mount const newShuffledOpts: Record = {}; - for (const q of quiz.questions) { + for (const q of initialQuiz.questions) { newShuffledOpts[q.id] = [...q.options].sort(() => Math.random() - 0.5); } setShuffledOptions(newShuffledOpts); // If retaking specific questions - if (retakeIds && retakeIds.length > 0) { - setOrder(retakeIds); + if (initialRetakeIds && initialRetakeIds.length > 0) { + setOrder(initialRetakeIds); + setAttemptScope(initialRetakeIds); setCurrentIndex(0); setAnswers({}); setSubmittedAnswers([]); @@ -82,62 +113,120 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu // Shared links: load from localStorage if (isShared) { try { - const saved = localStorage.getItem(`quiz_progress_${quiz.id}`); + const saved = localStorage.getItem(sharedStorageKey); if (saved) { - const parsed = JSON.parse(saved); - setOrder(parsed.order || quiz.questions.map(q => q.id)); - setCurrentIndex(parsed.currentIndex || 0); - setAnswers(parsed.answers || {}); - setSubmittedAnswers(Object.keys(parsed.answers || {})); + const parsed: unknown = JSON.parse(saved); + const record = + typeof parsed === "object" && parsed !== null + ? (parsed as Record) + : {}; + const liveIds = initialQuiz.questions.map((question) => question.id); + const optionsByQuestion = new Map( + initialQuiz.questions.map((question) => [ + question.id, + new Set(question.options.map((option) => option.id)), + ]) + ); + const restored = normalizeProgress({ + orderJson: record.order, + currentIndex: record.currentIndex, + dataJson: record.answers, + liveIds, + isValidValue: (value, questionId): value is string[] => + Array.isArray(value) && + new Set(value).size === value.length && + value.every( + (optionId) => + typeof optionId === "string" && + optionsByQuestion.get(questionId)?.has(optionId) === true + ), + }); + const restoredSet = new Set(restored.order); + const restoredOrder = [ + ...restored.order, + ...liveIds.filter((id) => !restoredSet.has(id)), + ]; + setOrder(restoredOrder); + setAttemptScope(restoredOrder); + setCurrentIndex( + restoredOrder.length === 0 + ? 0 + : Math.min(restored.currentIndex, restoredOrder.length - 1) + ); + setAnswers(restored.data); + setSubmittedAnswers(Object.keys(restored.data)); + if (restored.wasRecovered || restoredOrder.length !== restored.order.length) { + setRestoreWarning( + "Some saved quiz progress was invalid or referenced changed questions, so it was safely repaired." + ); + } setToastMessage("Session restored"); setLoading(false); return; } - } catch (e) { + } catch { // Fallback to defaults } } // Otherwise, normal sequential logic checking for progress - const prog = quiz.progress?.find((p) => p.mode === "SEQUENTIAL"); + const prog = initialProgress; if (prog) { - let savedOrder: string[] = []; - try { - savedOrder = JSON.parse(prog.orderJson); - } catch { - savedOrder = quiz.questions.map((q) => q.id); + const restored = normalizeProgress({ + orderJson: prog.orderJson, + currentIndex: prog.currentIndex, + dataJson: prog.answersJson, + liveIds: initialQuiz.questions.map((question) => question.id), + isValidValue: (value, questionId): value is string[] => { + const question = initialQuiz.questions.find((item) => item.id === questionId); + const optionIds = new Set(question?.options.map((option) => option.id) ?? []); + return Array.isArray(value) && + new Set(value).size === value.length && + value.every((id) => typeof id === "string" && optionIds.has(id)); + }, + }); + const liveIds = initialQuiz.questions.map((question) => question.id); + const restoredSet = new Set(restored.order); + const restoredOrder = [ + ...restored.order, + ...liveIds.filter((id) => !restoredSet.has(id)), + ]; + setOrder(restoredOrder); + setAttemptScope(restoredOrder); + setCurrentIndex( + restoredOrder.length === 0 + ? 0 + : Math.min(restored.currentIndex, restoredOrder.length - 1) + ); + setAnswers(restored.data); + setSubmittedAnswers(Object.keys(restored.data)); + if (restored.wasRecovered || restoredOrder.length !== restored.order.length) { + setRestoreWarning( + "Some saved quiz progress was invalid or referenced deleted questions, so it was safely repaired." + ); } - - let savedAnswers: Record = {}; - if (prog.answersJson) { - try { - savedAnswers = JSON.parse(prog.answersJson); - } catch {} - } - - setOrder(savedOrder); - setCurrentIndex(Math.min(prog.currentIndex, savedOrder.length - 1)); - setAnswers(savedAnswers); - setSubmittedAnswers(Object.keys(savedAnswers)); } else { - const freshOrder = quiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5); + const freshOrder = initialQuiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5); setOrder(freshOrder); + setAttemptScope(freshOrder); setCurrentIndex(0); setAnswers({}); setSubmittedAnswers([]); } setLoading(false); - }, [quiz, retakeIds, isShared]); + }, 0); + return () => window.clearTimeout(timer); + }, [sessionIdentity, isShared, sharedStorageKey, initialQuiz, initialRetakeIds, initialProgress]); // Persist progress as you go function saveProgress( idx: number, ans: Record ) { - if (retakeIds) return; // Don't persist retake partial state + if (attemptScope.length !== quiz.questions.length) return; if (isShared) { - localStorage.setItem(`quiz_progress_${quiz.id}`, JSON.stringify({ + localStorage.setItem(sharedStorageKey, JSON.stringify({ currentIndex: idx, order: order, answers: ans, @@ -145,18 +234,32 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu return; } - fetch("/api/progress", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - contentType: "QUIZ", - contentId: quiz.id, - mode: "SEQUENTIAL", - currentIndex: idx, - orderJson: JSON.stringify(order), - answersJson: JSON.stringify(ans), - }), - }).catch(() => {}); + const revision = ++progressRevisionRef.current; + saveQueueRef.current = saveQueueRef.current + .then(async () => { + const response = await fetch("/api/progress", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contentType: "QUIZ", + contentId: quiz.id, + mode: "SEQUENTIAL", + currentIndex: idx, + order, + answers: ans, + sessionId: progressSessionIdRef.current, + revision, + }), + }); + if (response.status === 409) { + setToastMessage("Progress changed in another session; reload to continue safely."); + return; + } + if (!response.ok) throw new Error("Progress save failed"); + }) + .catch(() => { + setToastMessage("Progress could not be saved. Your answers remain on screen."); + }); } if (loading) return
Loading quiz...
; @@ -217,18 +320,21 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu } async function handleFinish() { + if (finishingRef.current) return; + finishingRef.current = true; + setIsFinishing(true); + setFinishError(null); if (isShared) { - localStorage.removeItem(`quiz_progress_${quiz.id}`); + localStorage.removeItem(sharedStorageKey); } // If shared, grade it locally and show results, don't ping backend if (isShared) { // Simulate an attempt object let runningScore = 0; - let maxScore = quiz.questions.length; // Actually, depends on retakeIds, but shared doesn't support retakeIds typically - if (retakeIds) maxScore = retakeIds.length; + const maxScore = attemptScope.length; - const scoredItems = (retakeIds || quiz.questions.map(q => q.id)).map(qId => { + const scoredItems = attemptScope.map(qId => { const q = quiz.questions.find((x) => x.id === qId); if (!q) return 0; const formattedQ = { @@ -245,45 +351,40 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu score: runningScore, maxScore: maxScore, answersJson: JSON.stringify(answers), - isPartialRetake: false, + isPartialRetake: attemptScope.length !== quiz.questions.length, completedAt: new Date().toISOString(), }; setResultsData(attempt); + finishingRef.current = false; + setIsFinishing(false); return; } - // Send to backend - const isPartialRetake = !!retakeIds; try { + await saveQueueRef.current; const res = await fetch(`/api/quizzes/${quiz.id}/attempt`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - answersJson: JSON.stringify(answers), - isPartialRetake, + questionIds: attemptScope, + answers, }), }); - if (res.ok) { - const attempt = await res.json(); - - // Clear progress - if (!isPartialRetake) { - await fetch("/api/progress", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - contentType: "QUIZ", - contentId: quiz.id, - mode: "SEQUENTIAL", - }), - }); - } - - setResultsData(attempt); + if (!res.ok) { + const payload = (await res.json().catch(() => null)) as + | { error?: string } + | null; + throw new Error(payload?.error ?? "Could not save this attempt"); } - } catch (e) { - console.error(e); + setResultsData(await res.json()); + } catch (error) { + setFinishError( + error instanceof Error ? error.message : "Could not save this attempt" + ); + } finally { + finishingRef.current = false; + setIsFinishing(false); } } @@ -295,11 +396,13 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu onRetake={(missedIds: string[]) => { // Restart this component with retakeIds setOrder(missedIds); + setAttemptScope(missedIds); setCurrentIndex(0); setAnswers({}); setSubmittedAnswers([]); submittingQuestionRef.current = null; setResultsData(null); + setFinishError(null); // Re-shuffle options for the new attempt const newShuffledOpts: Record = {}; @@ -319,6 +422,11 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu return (
+ {restoreWarning && ( +
+ {restoreWarning} +
+ )} {/* Toast Notification */} {toastMessage && (
@@ -504,12 +612,20 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu Next Question ) : ( - +
+ {finishError && ( +
+ {finishError} Your answers are still here; try again. +
+ )} + +
)}
diff --git a/src/components/spaced-repetition/SpacedRepetitionSets.tsx b/src/components/spaced-repetition/SpacedRepetitionSets.tsx index af0b9b3..574afc9 100644 --- a/src/components/spaced-repetition/SpacedRepetitionSets.tsx +++ b/src/components/spaced-repetition/SpacedRepetitionSets.tsx @@ -188,7 +188,16 @@ export function SpacedRepetitionSets() { useEffect(() => { const timer = window.setTimeout(() => void load(), 0); - return () => window.clearTimeout(timer); + function refreshWhenVisible() { + if (document.visibilityState === "visible") void load(); + } + window.addEventListener("focus", refreshWhenVisible); + window.addEventListener("study-decks-changed", refreshWhenVisible); + return () => { + window.clearTimeout(timer); + window.removeEventListener("focus", refreshWhenVisible); + window.removeEventListener("study-decks-changed", refreshWhenVisible); + }; }, [load]); async function createSet() { diff --git a/src/components/ui/Navbar.tsx b/src/components/ui/Navbar.tsx index fb9a6c3..55e0c85 100644 --- a/src/components/ui/Navbar.tsx +++ b/src/components/ui/Navbar.tsx @@ -21,6 +21,8 @@ export function Navbar() { const parts = pathname.split("/").filter(Boolean); const classSlug = parts[0] && parts[0] !== "shared" ? parts[0] : null; const [hasReadyCards, setHasReadyCards] = useState(false); + const [loggingOut, setLoggingOut] = useState(false); + const [logoutError, setLogoutError] = useState(null); const refreshDueCards = useCallback(async () => { if (!classSlug) return; @@ -53,9 +55,18 @@ export function Navbar() { }, [refreshDueCards]); async function handleLogout() { - await fetch("/api/auth/logout", { method: "POST" }); - router.push("/login"); - router.refresh(); + setLoggingOut(true); + setLogoutError(null); + try { + const response = await fetch("/api/auth/logout", { method: "POST" }); + if (!response.ok) throw new Error("Sign out failed. Your session is still active."); + router.push("/login"); + router.refresh(); + } catch (error) { + setLogoutError(error instanceof Error ? error.message : "Sign out failed. Please retry."); + } finally { + setLoggingOut(false); + } } const nav = ( @@ -110,7 +121,8 @@ export function Navbar() { Appearance
-
diff --git a/src/components/ui/ShareMenu.tsx b/src/components/ui/ShareMenu.tsx index 1aa85b5..d1a57b3 100644 --- a/src/components/ui/ShareMenu.tsx +++ b/src/components/ui/ShareMenu.tsx @@ -1,21 +1,29 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; interface ShareMenuProps { targetType: "DECK" | "QUIZ" | "GROUP"; contentId: string; classSlug: string; compact?: boolean; + containsQuizAnswers?: boolean; } -export function ShareMenu({ targetType, contentId, classSlug, compact = false }: ShareMenuProps) { +export function ShareMenu({ + targetType, + contentId, + classSlug, + compact = false, + containsQuizAnswers = targetType === "QUIZ", +}: ShareMenuProps) { const [token, setToken] = useState(null); const [loading, setLoading] = useState(true); const [open, setOpen] = useState(false); const [copied, setCopied] = useState(false); const [isGroupShared, setIsGroupShared] = useState(false); const [groupName, setGroupName] = useState(null); + const [error, setError] = useState(null); const buttonRef = useRef(null); const menuRef = useRef(null); @@ -35,28 +43,55 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }: return () => document.removeEventListener("pointerdown", handlePointerDown); }, [open]); + const loadShareInfo = useCallback(async (signal?: AbortSignal) => { + setLoading(true); + setError(null); + try { + const response = await fetch(`/api/share?targetType=${targetType}&contentId=${contentId}`, { signal }); + if (!response.ok) throw new Error("Sharing settings could not be loaded."); + const data = await response.json() as { + token?: string | null; + isGroupShared?: boolean; + groupName?: string | null; + }; + setToken(data.token ?? null); + setIsGroupShared(Boolean(data.isGroupShared)); + setGroupName(data.groupName ?? null); + } catch (loadError) { + if (signal?.aborted) return; + setError(loadError instanceof Error ? loadError.message : "Sharing settings could not be loaded."); + } finally { + if (!signal?.aborted) setLoading(false); + } + }, [targetType, contentId]); + useEffect(() => { if (!open) return; - fetch(`/api/share?targetType=${targetType}&contentId=${contentId}`) - .then(res => res.json()) - .then(data => { - setToken(data.token); - setIsGroupShared(data.isGroupShared || false); - setGroupName(data.groupName || null); - }) - .finally(() => setLoading(false)); - }, [open, targetType, contentId]); + const controller = new AbortController(); + const timer = window.setTimeout(() => void loadShareInfo(controller.signal), 0); + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [open, loadShareInfo]); async function handleToggle() { setLoading(true); + setError(null); try { const res = await fetch("/api/share", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ targetType, contentId }), }); + if (!res.ok) { + const response = await res.json().catch(() => null) as { error?: string } | null; + throw new Error(response?.error ?? "Sharing could not be updated."); + } const data = await res.json(); setToken(data.token); + } catch (toggleError) { + setError(toggleError instanceof Error ? toggleError.message : "Sharing could not be updated."); } finally { setLoading(false); } @@ -73,9 +108,13 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }: : "quizzes"; const itemQuery = isGroupShared ? `?itemId=${encodeURIComponent(contentId)}` : ""; const url = `${window.location.origin}/shared/${classSlug}/${typeStr}/${token}${itemQuery}`; - await navigator.clipboard.writeText(url); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + try { + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setError("Clipboard access failed. Retry or copy the URL from your browser."); + } } return ( @@ -85,6 +124,7 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }: onClick={() => setOpen(!open)} className={`${compact ? "h-8 w-8 rounded-lg" : "h-10 w-10 rounded-xl"} grid place-items-center text-text-muted transition-colors hover:bg-bg-surface-alt hover:text-primary`} title="Share" + aria-label="Share settings" > @@ -99,6 +139,11 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }: {loading ? (
+ ) : error ? ( +
+

{error}

+ +
) : (
@@ -106,6 +151,7 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }: -

{title}

-
{minutes}:{seconds}
- - {children} -
- ); -} diff --git a/src/components/arcade/ArcadeImportModal.tsx b/src/components/arcade/ArcadeImportModal.tsx deleted file mode 100644 index 6378d34..0000000 --- a/src/components/arcade/ArcadeImportModal.tsx +++ /dev/null @@ -1,127 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { GenerateTab } from "@/components/import/GenerateTab"; -import type { ArcadeGameKey, ArcadeImportBatchPreview } from "@/types/arcade"; - -export function ArcadeImportModal({ - classId, - gameType, - onClose, - onImported, -}: { - classId: string; - gameType: ArcadeGameKey; - onClose: () => void; - onImported: () => void; -}) { - const [tab, setTab] = useState<"generate" | "import">("import"); - const [rawJson, setRawJson] = useState(""); - const [names, setNames] = useState([]); - const [preview, setPreview] = useState(null); - const [error, setError] = useState(""); - const [details, setDetails] = useState([]); - const [acknowledged, setAcknowledged] = useState(false); - const [busy, setBusy] = useState(false); - - async function previewImport() { - setBusy(true); - setError(""); - setDetails([]); - setPreview(null); - try { - const response = await fetch("/api/arcade/import/preview", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ gameType, rawJson }), - }); - const data = await response.json(); - if (!response.ok) { - setError(data.error ?? "Import preview failed"); - setDetails(data.details ?? []); - return; - } - setPreview(data); - setNames(data.packs.map((pack: { name: string }) => pack.name)); - } finally { - setBusy(false); - } - } - - async function saveImport() { - if (!preview || names.some((name) => !name.trim())) return; - setBusy(true); - setError(""); - try { - const response = await fetch("/api/arcade/packs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - classId, - gameType, - rawJson, - ...(gameType === "connections" ? { names: names.map((name) => name.trim()) } : { name: names[0]?.trim() }), - warningsAcknowledged: acknowledged, - }), - }); - const data = await response.json(); - if (!response.ok) { - setError(data.error ?? "Import failed"); - setDetails(data.details ?? []); - return; - } - onImported(); - } finally { - setBusy(false); - } - } - - return ( -
- -
-
- {(["generate", "import"] as const).map((item) => ( - - ))} -
-
- {tab === "generate" ? : ( -
-

{gameType === "connections" ? "Paste one pack object or an array of up to ten packs." : "Paste one Crossword pack containing exactly 80 entries."} Every board is validated before anything is saved.

-