Add 21 files, update 16 files, rename 1 file

This commit is contained in:
[dyad] committed 2026-08-02 17:26:49 +02:00
1 parent b9e4ed4dfc
commit 278293b397
39 files changed
+2434 -61

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
.git
.github
.next
node_modules
test-results
playwright-report
db
data
.env
.env.local
*.log
docs
+36 -4
View File
@@ -1,5 +1,37 @@
# CompetenceTrack / ExaMetra — Environment Configuration # SchoolOS environment configuration
# Copy this file to .env and adjust values as needed # Copy this file to .env. Do not commit a populated .env file.
# Database URL (SQLite via Prisma) # SQLite database. Docker Compose overrides this with /data/db/schulos.db.
DATABASE_URL=file:./db/custom.db DATABASE_URL=file:./db/schulos.db
# Public application URL. Use the externally reachable HTTPS URL in production.
# Multiple comma-separated trusted origins are allowed for approved deployment URLs.
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Session and deployment configuration
NODE_ENV=production
SESSION_MAX_AGE_SECONDS=604800
PASSWORD_RESET_TTL_SECONDS=3600
# Self-hosted default: new accounts are verified immediately. Set true only when
# an external email verification delivery flow is configured.
AUTH_REQUIRE_EMAIL_VERIFICATION=false
# Development-only: return reset tokens in API responses instead of email.
AUTH_SHOW_RESET_TOKEN=false
SCHULOS_PORT=3000
# Local filesystem storage. Docker Compose maps these paths to a persistent volume.
STORAGE_PATH=./data/uploads
EXPORT_PATH=./data/exports
BACKUP_PATH=./data/backups
# Optional mail delivery for password reset and notifications.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
# Optional AI provider configuration. Leave disabled unless the school enables AI.
AI_ENABLED=false
AI_PROVIDER=
AI_API_KEY=
+44
View File
@@ -0,0 +1,44 @@
name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
quality:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
DATABASE_URL: file:./ci.db
NEXT_PUBLIC_APP_URL: http://localhost:3000
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Validate Prisma schema
run: pnpm prisma validate
- name: Generate Prisma client
run: pnpm prisma generate
- name: Apply migrations to disposable database
run: pnpm prisma migrate deploy
- name: Check migration drift
run: pnpm prisma migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --exit-code
- name: Lint
run: pnpm lint
- name: Unit tests
run: pnpm test
- name: Typecheck and production build
run: pnpm typecheck
- name: Audit production dependencies
run: pnpm audit --prod --audit-level=high
+1
View File
@@ -43,6 +43,7 @@ next-env.d.ts
# database # database
db/*.db db/*.db
db/*.db-journal db/*.db-journal
data/
prisma/*.db prisma/*.db
prisma/*.db-journal prisma/*.db-journal
+32
View File
@@ -0,0 +1,32 @@
FROM node:22-alpine AS base
WORKDIR /app
FROM base AS dependencies
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && pnpm install --frozen-lockfile
FROM base AS builder
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
RUN corepack enable && pnpm prisma generate && pnpm build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 schulos && adduser --system --uid 1001 schulos
COPY --from=builder /app/public ./public
COPY --from=builder --chown=schulos:schulos /app/.next/standalone ./
COPY --from=builder --chown=schulos:schulos /app/.next/static ./.next/static
COPY --from=builder --chown=schulos:schulos /app/prisma ./prisma
COPY --from=builder --chown=schulos:schulos /app/node_modules/.prisma ./node_modules/.prisma
RUN mkdir -p /data/db /data/uploads /data/exports /data/backups && chown -R schulos:schulos /data
USER schulos
EXPOSE 3000
CMD ["node", "server.js"]
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 Mustafa Cetin Copyright (c) 2026 Defne Chetin
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
View File
Whitespace-only changes.
+278
View File
@@ -0,0 +1,278 @@
# SchoolOS PRD Execution Plan
Last reviewed: 2026-08-02
## Operating Rules
- [x] Use SQLite with Prisma for core relational data.
- [x] Keep all server-side access checks deny-by-default and scope teachers to currently assigned classes.
- [x] Keep student data restricted to the linked student record; restrict parent data to `ParentStudentLink` children.
- [x] Prevent Vice Principals from managing administrator accounts.
- [ ] Move every configurable policy, threshold, retention period, warning limit, and escalation window into database-backed school policy records. No new business-rule literals may be introduced.
- [ ] Every new API route must have authorization, school scope, input validation, audit logging where it mutates personal or grading data, and tests.
- [ ] Keep all dates/deadlines in Calendar as the canonical source; other pages can show only read-only summaries sourced from Calendar.
- [ ] Keep notifications in the Notification module as the single delivery pipeline; feature modules emit notifications rather than creating independent alert systems.
- [ ] Do not use emojis in product UI, seed-visible UI, navigation, documentation examples, or new copy. Use Lucide icons already present in the project.
- [ ] Preserve the existing emerald/teal brand palette and semantic colors defined in `src/app/globals.css`.
- [ ] Use one shared motion vocabulary: 160-220ms hover/focus feedback, 220-320ms panel/state transitions, opacity/transform only, and `prefers-reduced-motion` fallbacks.
- [ ] New dashboards use responsive Bento grids. Widgets are read-only views of their owning module and must not duplicate workflows.
## Definition Of Done
- [ ] `pnpm lint`, `pnpm test`, `pnpm typecheck`, and E2E smoke tests pass.
- [ ] A feature has server-side authorization tests including cross-school IDOR attempts.
- [ ] A feature has i18n coverage in German and English with no new hardcoded UI strings.
- [ ] Mobile/tablet interaction meets 44x44px minimum target size and keyboard/screen-reader requirements.
- [ ] Documentation, migration, seed behavior, audit logging, and retention implications are updated.
## Completed Foundation
- [x] Next.js, TypeScript, Tailwind, shadcn/ui, Prisma, SQLite, Recharts, and DE/EN i18n scaffold exist.
- [x] Core school, year, class, enrollment, student, subject, competency, progress, assessment, grading, reporting, audit, export, and soft-delete models exist.
- [x] Competency templates, entries, assessments, grading schemes, computed grades, competence flower views, report card screens, data export, and GDPR/compliance models exist.
- [x] Student/parent/teacher/admin API scope hardening has been applied across major academic, personal, operational, sensitive-data, and report endpoints.
- [x] Role-safe communication, leave approval, teacher absence, calendar, and escalation baseline workflows exist.
- [x] Compliance models exist: `DataRetentionPolicy`, `DpiaRecord`, `ComplianceConsultation`, `SchoolComplianceStatus`, and `DeletionFlag`.
- [x] Basic signage, exam, notification, dashboard widget, and notebook models/views exist.
- [x] Seed scripts and `.env.example` exist.
- [x] Current checks pass: lint, unit policy tests, build-first typecheck, and production build.
## Information Architecture And Navigation Consolidation
### Canonical Top-Level Navigation
Use these sections only. Do not add a new sidebar entry if a workflow belongs in an existing page tab.
- [ ] Home
- Dashboard only. Role-specific Bento widgets, quick actions, unread notifications, and read-only upcoming calendar summary.
- [ ] Teach
- Classes, Students, Competencies, Progress, Assessments, Grading, Reports, Attendance, Homework, Lesson Plans, Resources, Digital Notebook.
- [ ] School Life
- Calendar, Communication, Announcements, Events, Library, Transport, Wellness, Counseling, Discipline, Substitutions.
- [ ] Insights
- Competence Flower, Mastery Matrix, Analytics, Curriculum Coverage, Grade Analytics, Data Export.
- [ ] Administration
- Users/Roles, School Setup, Policies, Compliance, Imports, Backups, Signage, Settings.
### Required Page Ownership And Tab Consolidation
- [ ] `Classes` owns class setup and roster. Tabs: Overview, Students, Subjects, Staff, Timetable, Seating, Attendance, Competencies.
- [ ] `Student Detail` owns an individual student record. Tabs: Overview, Progress, Assessments, Reports, Attendance, Wellbeing, Goals, Notebook, Portfolio, Support. Do not expose the same student workflows as independent menu pages.
- [ ] `Assessments` owns authoring, competency links, results grid, review, and Exam Mode launch. Tabs: Assessments, Results, Rubrics, Peer/Self Assessment, Exam Sessions.
- [ ] `Reports` owns report drafts, review, finalization, PDF/ZIP exports, and report templates. Tabs: Drafts, Finalized, Templates, Export Queue.
- [ ] `Communication` owns direct threads, class/grade rooms, announcements, escalation queue, and messaging locks. Tabs: Inbox, Groups, Escalations, Announcements, Locks.
- [ ] `Calendar` owns all deadlines, events, exam schedules, absences, timetable reflection, holidays, and availability. Tabs: Calendar, Timetable, Absences, Exam Dates, School Events, Holidays.
- [ ] `Resources` owns Learning Hub content. Tabs: Browse, My Class Resources, Worksheets, Vocabulary, Games, Review Queue.
- [ ] `Administration > Policies` owns permission policies, escalation policies, warning thresholds, quiet hours, module flags, and retention settings.
- [ ] Remove or fold duplicate sidebar entries after each consolidated page is delivered. Update role access lists and command palette at the same time.
## Phase 18 - Delivery, Security, And Maintainer Baseline
- [x] Add `Dockerfile` and `docker-compose.yml` with persistent volumes for SQLite, generated exports, uploads, and backups.
- [x] Expand `.env.example` with database path, public base URL, mail/reset configuration, storage path, backup path, feature flags, and optional AI configuration.
- [x] Add `docs/DATA_MODEL.md`, `docs/CONTRIBUTING.md`, and `docs/ROADMAP.md`.
- [x] Add `docs/ARCHITECTURE.md` and `docs/SELF_HOSTING.md`.
- [x] Move the root PRD to `docs/PRD.md` and keep one canonical index in `docs/README.md`.
- [ ] Add GitHub Actions for lint, typecheck, unit tests, Prisma validation, migration drift checks, and Playwright smoke tests. Prisma migration deploy/drift, lint, typecheck, unit, and dependency audit checks are implemented; Playwright smoke tests wait on the test environment.
- [ ] Add dependency audit and secret scanning in CI.
- [x] Replace `bcryptjs` password hashing with Argon2id and document a safe rehash-on-login migration path. Legacy bcrypt hashes verify once and rehash on successful login.
- [ ] Implement password reset tokens, email verification/self-hosted bypass flag, configurable session expiry, optional TOTP setup/recovery codes, and rate-limited auth routes. Reset tokens, self-hosted verification bypass, and session configuration are implemented; TOTP/recovery codes remain.
- [x] Implement central origin-based CSRF protection for cookie-authenticated mutation requests below `/api/`.
- [x] Add real local file storage abstraction for uploads, exports, and backups with path traversal protection. S3-compatible adapter remains a future implementation behind the same interface.
- [x] Add SQLite WAL configuration and verified online backup scripts. Restore remains a deliberate maintenance-environment workflow and requires a smoke test before production cutover.
Exit criteria:
- [ ] A non-technical school admin can deploy from clone to login in under 10 documented commands.
- [ ] Authentication tests cover login rate limits, reset flow, session expiry, CSRF rejection, and TOTP when enabled.
- [ ] Backups are real files, restore into a disposable database is verified, and no endpoint returns a fake restore acknowledgment.
## Phase 19 - Policy And Permission Engine
- [ ] Create `SchoolPolicy`, `EscalationPolicy`, `ClassMessagingLock`, module activation, quiet-hours, warning-limit, and holiday/calendar policy tables.
- [ ] Replace hardcoded escalation days, weekend calculation, XP constants, leave types, notification thresholds, and warning counts with policy/config queries.
- [ ] Implement a centralized policy service with cached, scoped school configuration and explicit fallback behavior for missing required policy rows.
- [ ] Add an Admin Policy Console with tabs: Access, Communication, Exams, Notifications, Retention, Modules, School Calendar.
- [ ] Add DPO read/gate permissions without granting ordinary user administration.
- [ ] Add policy change audit records with before/after value, actor, reason, and effective time.
- [ ] Use holiday records from Calendar to calculate business days; do not assume Mon-Fri alone.
- [ ] Implement the urgent safety path that bypasses normal communication escalation while preserving audit and notification traceability.
Exit criteria:
- [ ] An administrator can change each PRD-configurable rule without deployment.
- [ ] Policy unit tests cover missing policies, precedence, school isolation, holidays, overrides, and emergency bypass.
## Phase 20 - Navigation, Design System, And Dashboards
- [ ] Refactor `app-layout.tsx` sidebar and command palette into the canonical five navigation sections.
- [ ] Remove duplicate or overlapping navigation items after moving their workflows to canonical page tabs.
- [ ] Create shared page-shell primitives: `PageHeader`, `PageTabs`, `BentoGrid`, `BentoWidget`, `ModuleEmptyState`, `PermissionEmptyState`, and `LoadingSkeleton`.
- [ ] Create a single icon registry based on Lucide. Remove any emoji-based UI affordances and prohibit adding new ones.
- [ ] Add shared animation tokens/utilities and reduced-motion behavior; remove inconsistent bespoke animations from feature pages during touch work.
- [ ] Rebuild Dashboard as role-aware Bento grid with persisted widget configuration, not duplicated business logic.
- [ ] Dashboard widget catalog: My Classes, Follow-up Students, Upcoming Calendar, Pending Approvals, Notifications, Exam Monitor, Report Deadlines, Compliance Gate, Resource Review Queue.
- [ ] Give every role a narrow dashboard: teacher classroom operations, parent child summary, student personal learning, vice principal operational oversight, admin governance, DPO compliance.
- [ ] Standardize tablet layout, touch targets, focus order, screen-reader labels, density, cards, tabs, tables, dialogs, and empty/error states.
Exit criteria:
- [ ] No workflow is reachable from more than one sidebar item.
- [ ] Every duplicate workflow has one canonical owner and optional read-only dashboard summaries only.
- [ ] Desktop, tablet, and mobile visual tests cover every top-level navigation section.
## Phase 21 - Academics Core Completion
- [ ] Ensure template assignment clones categories, competencies, and mastery definitions atomically into a school-owned editable copy.
- [ ] Add explicit clone version/history and prevent global-template mutations by school editors.
- [ ] Build roster-first fast entry flow: keyboard-first, tablet-first, under three interactions for the common entry case.
- [ ] Implement append-only progress entries with configurable original-author edit window and immutable audit history.
- [ ] Add configurable competence flower aggregation per template: latest, average of last N, weighted recency.
- [ ] Add flower drilldown to source entries and PNG/SVG export.
- [ ] Complete assessments: delivery mode, competency links, bulk results grid, validation, and class enrollment consistency checks.
- [ ] Move grade computation into a pure tested service with transparent per-rule breakdown.
- [ ] Add `finalizedByUserId`, finalization time, mandatory teacher confirmation, and immutable final-grade audit semantics.
- [ ] Handle grade edge cases: missing inputs, verbal-only scales, partial enrollment, override reason, and school-specific grade scales.
- [ ] Complete report lifecycle: Draft -> Review -> Final -> Versioned, locked final records, revision workflow, data-driven phrase templates.
- [ ] Implement real A4 PDF output, school branding, optional flower/notebook appendix, single download, and ZIP class export.
Exit criteria:
- [ ] A teacher can create a class, clone a template, add entries, enter an assessment grid, inspect flower evidence, compute/finalize grades, and export one or many reports.
- [ ] Grading unit tests cover deterministic breakdown and edge cases.
## Phase 22 - Communication And Notification Completion
- [ ] Replace `CommunicationRoom`-only escalation behavior with policy-backed `EscalationPolicy` and `EscalationRequest` records.
- [ ] Implement class, grade-level, and custom moderated social rooms with real membership and content moderation states.
- [ ] Implement class-hours messaging lock using Calendar/Timetable period boundaries; urgent messages still deliver.
- [ ] Add message read receipts, attachments through the storage abstraction, moderation queue, and retention references.
- [ ] Add counselor role/department routing for eligible student escalations.
- [ ] Provide an administrator case override with reason/audit trail.
- [ ] Centralize all notifications into one delivery pipeline: in-app center, browser push, email fallback where configured, quiet hours, emergency bypass.
- [ ] Remove duplicate feature-specific notification UI; retain only contextual status banners plus the central notification center.
- [ ] Add notification preference and delivery failure/retry observability.
Exit criteria:
- [ ] Parent/student escalation uses a database policy and a holiday-aware deadline.
- [ ] Teachers message management directly; emergency contact is always available without escalation.
- [ ] One notification pipeline delivers all module events.
## Phase 23 - Calendar, Leave, Attendance, And School Operations
- [ ] Make Calendar the canonical event store for absences, exams, report deadlines, timetable, holidays, school events, and signage date windows.
- [ ] Add explicit event audience/visibility relation for student, parent, class, staff, and administrator-private events.
- [ ] Finish student leave workflow with evidence files, parent verification, teacher workflow queue, final admin approval, calendar publication, and auditable rejection reasons.
- [ ] Finish teacher leave workflow with vice-principal notification, private admin notes, class coverage link, and calendar audience controls.
- [ ] Add school holiday management and use it in escalation, attendance, reporting, and timetable computations.
- [ ] Consolidate attendance, illness, leave, and timetable visibility into Calendar tabs; keep data entry routes canonical.
- [ ] Complete substitutions: absence -> available substitute search -> assignment -> timetable/calendar reflection -> notifications.
- [ ] Complete transport and library operational flows with real permissions, return/overdue state, and audit records.
Exit criteria:
- [ ] No page stores a second independent date/deadline implementation.
- [ ] Every absence and approved leave has one linked calendar record with audience-aware visibility.
## Phase 24 - Learning Hub, AI Tutor, And Content Governance
- [ ] Replace generic `Resource` semantics with PRD-aligned resource types, review status, competency links, ownership, and school/global library behavior.
- [ ] Add worksheet solution access rules and PDF worksheet export with teacher-controlled solution inclusion.
- [ ] Add vocabulary sets/items with spaced repetition, student progress, and teacher assignment surfaces.
- [ ] Add game progress scoped by student, subject, and school; exclude addictive/profiling mechanics.
- [ ] Add resource search/filter by subject, grade, competency, and review status; surface weak-competency recommendations from the flower.
- [ ] Add AI content authoring queue: every AI draft is `PENDING_REVIEW`, never directly published.
- [ ] Add `AiTutorInteraction` with pseudonymous/summary-only default logging, retention link, consent/config setting, and audit access.
- [ ] Build server-side AI Tutor safety gate: deny when active digital exam session exists; enforce homework/assessment hint-only policy with structured request context.
- [ ] Add translation requests labeled AI-translated, review reporting, and no persistent language/ethnicity profiling.
- [ ] Keep AI provider/storage optional and local-first; do not require third-party paid services for core operation.
Exit criteria:
- [ ] AI Tutor cannot answer during an active locked exam even with a direct API request.
- [ ] AI-generated content cannot become visible before human review.
## Phase 25 - Exam Mode, PWA, And Digital Notebook
- [ ] Add `ExamIncidentEvent` model and complete exam lifecycle states, device IDs, reasons, teacher actions, and parent/admin visibility rules.
- [ ] Implement server-side active-exam middleware that blocks AI Tutor, Learning Hub assistance, and ordinary messaging APIs for that student.
- [ ] Build live teacher exam monitor with pause, resume, cancel, bathroom approval, distress handling, warning issuance, and incident timeline.
- [ ] Make third warning auto-pause only; require a human for any disciplinary record creation.
- [ ] Implement fullscreen/kiosk UX with accessibility fallback and documented browser limitations.
- [ ] Add Workbox service worker for offline shell, push delivery, navigation handling, and installable PWA behavior.
- [ ] Add notebook templates, typed blocks, structured stylus stroke data, non-destructive teacher annotations, page history, subject/year organization, and PDF export.
- [ ] Use worker offloading for costly stroke/image operations only after profiling; keep a no-worker fallback.
Exit criteria:
- [ ] Exam API integrity remains enforced if the client bypasses fullscreen.
- [ ] Notebook annotations never overwrite original student work.
## Phase 26 - Signage, Emergency, And Reliability
- [ ] Align signage schema with display, slide, playlist, approval, priority, date-window, and rotation requirements.
- [ ] Add teacher slide request queue; only policy-authorized content may publish.
- [ ] Implement emergency alerts as highest-priority display override with explicit resolve action and audit record.
- [ ] Send emergency notifications to configured staff/student/parent audiences with retry/failure telemetry.
- [ ] Implement staff-only counseling notice with no student identifying detail on public displays.
- [ ] Add signage display endpoint suitable for TV devices and test offline/reconnect behavior.
Exit criteria:
- [ ] Emergency alert override is independently tested for display, notification delivery, resolution, and audit logging.
## Phase 27 - Compliance, Rights, And Retention Completion
- [ ] Document purpose and legal basis for every personal-data entity in `docs/DATA_MODEL.md`.
- [ ] Add entity-to-retention-category mapping and scheduled retention sweep using `DataRetentionPolicy` rows.
- [ ] Ensure legally mandated retention values have no guessed defaults; require DPO/school confirmation before use.
- [ ] Complete unified staff/student/parent self-service export including messages, notebooks, progress, reports, permitted disciplinary records, and generated files.
- [ ] Complete partial erasure with retained-record explanation, file deletion, audit trail, and DPO approval where required.
- [ ] Generate RoPA from module/entity metadata and keep `docs/legal/verzeichnis-verarbeitungstaetigkeiten.md` reproducible.
- [ ] Make privacy notice generator derive configured retention and module states; include non-legal-advice disclaimer.
- [ ] Gate high-risk AI Tutor and Exam Mode activation on DPO/DPIA records and onboarding consultations.
- [ ] Add Personalrat module checklist/spec sheets describing monitoring, viewers, retention, and prohibited performance-evaluation use.
- [ ] Add module-level consultation status for DPO, Schultrager, Elternbeirat/Schulkonferenz, and Personalrat.
Exit criteria:
- [ ] High-risk modules cannot activate without the configured compliance gate.
- [ ] Export/erasure and retention workflows have end-to-end tests.
## Phase 28 - Testing, Accessibility, And Release Readiness
- [ ] Add unit tests for policy engine, authorization, business-day calculation with holidays, grading engine, report phrase generation, retention sweep, and exam gates.
- [ ] Add API IDOR matrix tests for each protected school/student/class resource and all roles.
- [ ] Add Playwright E2E: register/login, class setup, template clone, student import, progress entry, assessment grid, grade finalization, report PDF, parent portal, escalation, leave approval, exam gate.
- [ ] Add visual regression coverage for each canonical navigation page at desktop, tablet, and mobile breakpoints.
- [ ] Audit WCAG 2.1 AA: keyboard navigation, focus order, labels, dialogs, contrast, motion reduction, touch targets, table alternatives, and screen-reader announcements.
- [ ] Add performance budgets for roster, dashboard, flower, report generation, and tablet entry flows.
- [ ] Add production readiness review: migrations, seed behavior, backup restore drill, Docker deployment drill, no mock data in shipped UI, and license decision.
- [ ] Add `CHANGELOG.md` in `docs/` and release/version workflow.
Exit criteria:
- [ ] CI runs lint, typecheck, Prisma validation, unit tests, security checks, and Playwright smoke tests on every pull request.
- [ ] A clean environment can seed, deploy, back up, restore, and execute the critical teacher/parent/student paths.
## Suggested Execution Order
1. Phase 18: delivery/security baseline.
2. Phase 19: policy engine.
3. Phase 20: navigation/design system/dashboard consolidation.
4. Phase 21: academics completion.
5. Phase 22 and Phase 23 in parallel after policy/calendar primitives exist.
6. Phase 24 after policy, notification, and retention primitives exist.
7. Phase 25 after AI exam gate and PWA foundations are ready.
8. Phase 26 after notification reliability is proven.
9. Phase 27 throughout, with final compliance gates before release.
10. Phase 28 continuously, with final release gate after all feature phases.
## Current Blockers
- [ ] Confirm AGPLv3 versus MIT license direction.
- [ ] Confirm whether AI features remain optional and which local/provider integration is permitted.
- [ ] Confirm school-specific legal retention values with the DPO; do not infer them.
- [ ] Confirm emergency alert audience and after-hours teacher notification policy with school leadership.
- [ ] Confirm target browser/device policy for kiosk/fullscreen limitations.
+29
View File
@@ -0,0 +1,29 @@
services:
schulos:
build:
context: .
dockerfile: Dockerfile
container_name: schulos
restart: unless-stopped
environment:
DATABASE_URL: ${DATABASE_URL:-file:/data/db/schulos.db}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
SESSION_MAX_AGE_SECONDS: ${SESSION_MAX_AGE_SECONDS:-604800}
AI_ENABLED: ${AI_ENABLED:-false}
AI_PROVIDER: ${AI_PROVIDER:-}
AI_API_KEY: ${AI_API_KEY:-}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM: ${SMTP_FROM:-}
STORAGE_PATH: ${STORAGE_PATH:-/data/uploads}
EXPORT_PATH: ${EXPORT_PATH:-/data/exports}
BACKUP_PATH: ${BACKUP_PATH:-/data/backups}
ports:
- "${SCHULOS_PORT:-3000}:3000"
volumes:
- schulos-data:/data
volumes:
schulos-data:
+40
View File
@@ -0,0 +1,40 @@
# Architecture
SchoolOS is a single Next.js application with TypeScript, Prisma, and SQLite.
It is intentionally deployable as one self-hosted container for a single
school. Core personal data remains in SQLite; generated uploads, exports, and
backups live in persistent local storage.
## Components
- Next.js App Router hosts the UI and API routes.
- Prisma owns schema validation, migrations, and database access.
- SQLite is the default relational store. Use WAL mode in the deployment
bootstrap and retain the database plus `-wal` and `-shm` files during backups.
- The storage layer uses local filesystem paths first. Future S3-compatible
storage must use the same storage interface rather than feature-specific SDKs.
- `src/lib/storage.ts` is the single local filesystem boundary. It constrains
uploads, exports, and backups to configured roots and rejects path traversal.
- Notification, Calendar, Policy, and Authorization remain shared services;
feature modules must not create duplicate delivery, date, or permission logic.
## Deployment Boundary
`docker-compose.yml` runs the application with a named `/data` volume:
- `/data/db`: SQLite database files
- `/data/uploads`: uploaded evidence and documents
- `/data/exports`: generated reports and rights exports
- `/data/backups`: verified backups
Use a reverse proxy that terminates TLS in production. Configure
`NEXT_PUBLIC_APP_URL` with the public HTTPS URL.
## Security Notes
- Server-side authorization is mandatory; hiding navigation does not grant
access control.
- Secrets belong only in `.env` or deployment secret management.
- AI is optional and must remain disabled until policy/DPIA gates allow it.
- This engineering documentation is not legal advice. A school must involve its
DPO, Schultrager, and Personalrat before production use.
+51
View File
@@ -0,0 +1,51 @@
# Contributing
## Setup
1. Use Node.js 22 and pnpm.
2. Copy `.env.example` to `.env` and set `DATABASE_URL`.
3. Install dependencies with `pnpm install`.
4. Generate Prisma client with `pnpm db:generate`.
5. Create local schema with `pnpm db:push` or run the reviewed migration flow.
6. Seed only non-production data with `pnpm db:seed`.
7. Start the app with `pnpm dev`.
## Required Checks
Run these before opening a pull request:
```bash
pnpm prisma validate
pnpm lint
pnpm test
pnpm typecheck
git diff --check
```
## Engineering Rules
- Use server-side authorization for every route. UI visibility is never a
substitute for access control.
- Scope every school-owned query to the caller school. Scope teacher data to
assigned classes, student data to self, and parent data to linked children.
- Use `src/lib/access-policy.ts` and `src/lib/role-access.ts` instead of
duplicating access rules.
- Use `src/lib/storage.ts` for local files. Never assemble arbitrary paths from
request input.
- Use the Calendar and Notification modules as shared sources, not local copies
of deadlines or delivery logic.
- Keep new UI icon-based with Lucide. Do not introduce emoji UI affordances.
- Preserve existing palette, shared motion vocabulary, responsive behavior, and
role-aware page ownership defined in `TODO.md`.
- Add a migration for every Prisma schema change and update relevant docs/tests.
- Do not add hardcoded policy values when a school-configurable policy is
required.
## Pull Requests
- Keep a pull request narrowly scoped to one TODO phase item.
- Explain security, migration, audit, export/erasure, and retention effects.
- Include tests for authorization failures and cross-school IDOR attempts when
a route handles school or student data.
- Do not commit `.env`, database files, backup files, uploaded documents, or
generated test artifacts.
+77
View File
@@ -0,0 +1,77 @@
# Data Model
SchoolOS uses Prisma with SQLite. IDs are portable strings and timestamps are
stored in UTC. The schema is the source of truth; this document is a domain
map, not a replacement for `prisma/schema.prisma`.
## Organization And Identity
- `School`, `SchoolYear`, `SchoolDistrict`: organizational boundaries.
- `User`: authenticated account, role, school membership, locale, and soft
deletion state.
- `ClassGroup`, `ClassGroupTeacher`, `Enrollment`: teaching assignments and
current/historical class membership.
- `Student`, `ParentStudentLink`, `ParentContact`, `EmergencyContact`: student
profile and linked guardians. Access must always be evaluated through the
active student, parent link, class assignment, and school scope.
## Academic Record
- `Subject`, `CompetencyTemplate`, `CompetencyCategory`, `Competency`, and
`MasteryLevelDefinition` define editable school competency frameworks.
- `ClassCompetencyAssignment` links a framework to a class.
- `LearningProgressEntry` is the evidence record used by progress, mastery,
and competence visualizations.
- `Assessment`, `AssessmentCompetencyLink`, and `AssessmentResult` capture
assessment creation, competency links, and student results.
- `GradingScheme`, `GradingWeightRule`, and `ComputedGrade` hold calculation
rules and computed outcomes.
- `Report`, `ReportCardTemplate`, and `ReportSection` hold report-card drafts,
templates, and sections.
## Classroom Operations
- `AttendanceSession` and `AttendanceRecord`: attendance and absence status.
- `LessonPlan`, `Homework`, and `HomeworkSubmission`: instructional planning
and student work.
- `TimetableSlot`, `CalendarEvent`, `SchoolEvent`, and `EventRegistration`:
time-bound school activity.
- `TeacherAbsence`, substitute models, transport models, and library checkout
models support daily school operations.
## Student Support And Communication
- `TeacherNote`, `BehaviorCategory`, `BehaviorIncident`, and
`BehaviorIntervention`: staff-only support context.
- `IllnessReport`, `CounselingAppointment`, `HealthRecord`, and `Wellness`
models contain sensitive information; they require strict role, student, and
school checks.
- `CommunicationRoom`, `CommunicationRoomMember`, `CommunicationMessage`,
`ParentMessage`, `Announcement`, and `Notification` provide communication
and delivery records.
## Student Work And Learning Hub
- `Notebook`, `NotebookPage`, `NotebookPageVersion`, and `Drawing` store
digital student work and version history.
- `Resource`, `SubjectCategory`, `SubjectContent`, `SubjectTopic`,
`SubjectLesson`, and `LessonQuestion` represent learning content.
- Badge, reward, competition, portfolio, goal, and study-plan models support
student-facing learning workflows.
## Compliance And Audit
- `AuditLog` records sensitive operational actions.
- `DataExportRequest`, `DeletionFlag`, `DataRetentionPolicy`, `DpiaRecord`,
`ComplianceConsultation`, and `SchoolComplianceStatus` support rights,
retention, and governance workflows.
- `Backup` stores metadata for storage-backed backup files; backup files live
below `BACKUP_PATH` and must not be placed in the database.
## Change Rules
- Additive schema changes require a Prisma migration and an updated seed path.
- Never rename or remove a stored field without a migration/backfill plan.
- New personal-data fields require a purpose, access policy, audit requirement,
export behavior, erasure behavior, and retention category before release.
- New feature tables must include school scope where the data is school-owned.
+1116
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -138,7 +138,7 @@ schulos/
## License ## License
MIT License — see [LICENSE](./LICENSE) for details. MIT License — see [LICENSE](../LICENSE) for details.
## Contributing ## Contributing
+31
View File
@@ -0,0 +1,31 @@
# Roadmap
The execution roadmap is maintained in the root `TODO.md` checklist. This file
is the public summary of its delivery order.
## In Progress
- Delivery and security baseline: Docker, CI baseline, Argon2id, origin checks,
local storage, WAL, and verified online backups.
## Next
1. Complete account lifecycle security: reset tokens, verification, optional
TOTP, and auth-related end-to-end tests.
2. Build the school policy engine: configurable escalation, holidays, retention,
communication locks, quiet hours, and module gates.
3. Consolidate navigation into canonical module owners and tabbed pages; build
role-aware Bento dashboards without duplicate workflows.
4. Complete academic workflows: template cloning history, fast entry, grading
finalization attribution, versioned reports, real PDF and ZIP exports.
## Later
- Communication policy and notification delivery completion.
- Calendar, leave, substitution, and operations consolidation.
- Learning Hub governance, AI safety, and exam middleware.
- Digital notebook annotation and PWA support.
- Emergency signage, compliance automation, and full E2E/accessibility release
gates.
See `TODO.md` for detailed checkboxes, acceptance criteria, and blockers.
+95
View File
@@ -0,0 +1,95 @@
# Self Hosting
## Prerequisites
- Docker Engine with Docker Compose plugin
- A DNS name and HTTPS reverse proxy for production use
## Quick Start
1. Copy `.env.example` to `.env`.
2. Set `NEXT_PUBLIC_APP_URL` to the public application URL.
3. Review storage and optional SMTP values.
4. Build and start the service with `docker compose up -d --build`.
5. Review logs with `docker compose logs -f schulos`.
6. Run Prisma migrations inside the service when a release includes migrations.
7. Seed demo data only in a non-production instance with `pnpm db:seed`.
The service listens on port `3000` by default. Set `SCHULOS_PORT` in `.env` to
change the host port.
## Request Origin Protection
All mutation requests below `/api/` require a matching `Origin` or `Referer`.
Set `NEXT_PUBLIC_APP_URL` to the public HTTPS origin. For an approved second
origin, such as a separate admin host, provide comma-separated origins. Do not
include wildcard origins.
## Backup
Back up the complete persistent volume, including the database, uploads,
exports, and backups. For SQLite in WAL mode, preserve the main `.db` file and
its `-wal` and `-shm` companions or use SQLite's online backup command.
Test restores into a separate disposable deployment before relying on a backup.
The in-app backup endpoint writes a school-scoped JSON file to `BACKUP_PATH` and
verifies its readability before a restore drill; it does not automatically
overwrite a live database.
### WAL And Verified SQLite Backups
Run the following commands from the deployment directory after setting the
production environment values:
```bash
pnpm db:wal
pnpm db:backup
```
`db:wal` enables WAL mode, normal synchronous behavior, foreign-key checks, and
a checkpoint. `db:backup` uses SQLite's online `.backup` command, writes the
copy to `BACKUP_PATH`, and runs `PRAGMA integrity_check` on the result. A
successful command prints the backup file path, byte size, and `integrity: ok`.
Inside Docker, run the same commands with:
```bash
docker compose exec schulos pnpm db:wal
docker compose exec schulos pnpm db:backup
```
Do not restore onto the active school deployment. Copy the verified backup to a
separate maintenance environment, set its `DATABASE_URL` to the copied file,
and complete a login and data-read smoke test first.
## Upgrade
1. Take and verify a backup.
2. Pull the reviewed release.
3. Run `docker compose up -d --build`.
4. Run required Prisma migrations.
5. Confirm health, login, and critical role workflows.
## Password Hash Migration
New passwords use Argon2id. Existing bcrypt password hashes remain valid for
one successful login and are transparently upgraded to Argon2id at that time.
No bulk password reset is required for this migration.
## Account Recovery
Password reset requests create a one-time, SHA-256-hashed token with the TTL
configured by `PASSWORD_RESET_TTL_SECONDS`. The API always returns the same
success message whether or not an account exists. In development only, or when
`AUTH_SHOW_RESET_TOKEN=true`, the raw token is returned for local testing.
By default, self-hosted registrations are marked verified immediately. Set
`AUTH_REQUIRE_EMAIL_VERIFICATION=true` only after integrating a reviewed email
verification delivery flow. TOTP and recovery-code enrollment remain a planned
security enhancement.
## Compliance Notice
This software can support privacy-compliant operations but is not legal advice.
The deploying school must involve its DPO, Schultrager, and Personalrat before
processing real student data.
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse, type NextRequest } from 'next/server';
import { isCsrfProtectedRequest, isTrustedRequestOrigin } from '@/lib/csrf';
export function middleware(request: NextRequest) {
if (isCsrfProtectedRequest(request) && !isTrustedRequestOrigin(request)) {
return NextResponse.json({ error: 'Invalid request origin' }, { status: 403 });
}
return NextResponse.next();
}
export const config = {
matcher: ['/api/:path*'],
};
+3
View File
@@ -31,6 +31,8 @@
"db:seed": "bun prisma/seed.ts", "db:seed": "bun prisma/seed.ts",
"db:seed-demo": "bun prisma/seed-demo.ts", "db:seed-demo": "bun prisma/seed-demo.ts",
"db:setup-demo": "bun run db:push && bun run db:seed-demo", "db:setup-demo": "bun run db:push && bun run db:seed-demo",
"db:wal": "node scripts/sqlite-wal.mjs",
"db:backup": "node scripts/sqlite-backup.mjs",
"test": "bun test", "test": "bun test",
"typecheck": "next build --webpack && tsc --noEmit --pretty false" "typecheck": "next build --webpack && tsc --noEmit --pretty false"
}, },
@@ -72,6 +74,7 @@
"@reactuses/core": "^6.0.5", "@reactuses/core": "^6.0.5",
"@tanstack/react-query": "^5.82.0", "@tanstack/react-query": "^5.82.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"argon2": "^0.45.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
+47
View File
@@ -119,6 +119,9 @@ importers:
'@tanstack/react-table': '@tanstack/react-table':
specifier: ^8.21.3 specifier: ^8.21.3
version: 8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) version: 8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
argon2:
specifier: ^0.45.1
version: 0.45.1
bcryptjs: bcryptjs:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3 version: 3.0.3
@@ -472,6 +475,9 @@ packages:
'@emnapi/wasi-threads@1.2.1': '@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
'@epic-web/invariant@1.0.0':
resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
'@eslint-community/eslint-utils@4.10.1': '@eslint-community/eslint-utils@4.10.1':
resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -1121,6 +1127,10 @@ packages:
resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
'@phc/format@1.0.0':
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
engines: {node: '>=10'}
'@playwright/test@1.62.1': '@playwright/test@1.62.1':
resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -2321,6 +2331,10 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'} engines: {node: '>=8'}
argon2@0.45.1:
resolution: {integrity: sha512-skm+/WCjkGqCQxF7FG1LuZXM5yvbFjgbfiCGsud2oLgaDhh6b6dbH0b1EkghbM+xx4Bj8Ape+KKgixoIlWZicQ==}
engines: {node: '>=16.17.0'}
argparse@2.0.1: argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -2561,6 +2575,11 @@ packages:
crelt@1.0.7: crelt@1.0.7:
resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
cross-env@10.1.0:
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
engines: {node: '>=20'}
hasBin: true
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -3820,6 +3839,10 @@ packages:
node-addon-api@7.1.1: node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-addon-api@8.9.1:
resolution: {integrity: sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==}
engines: {node: ^18 || ^20 || >= 21}
node-exports-info@1.6.2: node-exports-info@1.6.2:
resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3827,6 +3850,10 @@ packages:
node-fetch-native@1.6.7: node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
node-releases@2.0.51: node-releases@2.0.51:
resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -5091,6 +5118,8 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@epic-web/invariant@1.0.0': {}
'@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))': '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))':
dependencies: dependencies:
eslint: 9.39.5(jiti@2.7.0) eslint: 9.39.5(jiti@2.7.0)
@@ -5741,6 +5770,8 @@ snapshots:
'@parcel/watcher-win32-arm64': 2.6.0 '@parcel/watcher-win32-arm64': 2.6.0
'@parcel/watcher-win32-x64': 2.6.0 '@parcel/watcher-win32-x64': 2.6.0
'@phc/format@1.0.0': {}
'@playwright/test@1.62.1': '@playwright/test@1.62.1':
dependencies: dependencies:
playwright: 1.62.1 playwright: 1.62.1
@@ -6907,6 +6938,13 @@ snapshots:
dependencies: dependencies:
color-convert: 2.0.1 color-convert: 2.0.1
argon2@0.45.1:
dependencies:
'@phc/format': 1.0.0
cross-env: 10.1.0
node-addon-api: 8.9.1
node-gyp-build: 4.8.4
argparse@2.0.1: {} argparse@2.0.1: {}
aria-hidden@1.2.6: aria-hidden@1.2.6:
@@ -7168,6 +7206,11 @@ snapshots:
crelt@1.0.7: {} crelt@1.0.7: {}
cross-env@10.1.0:
dependencies:
'@epic-web/invariant': 1.0.0
cross-spawn: 7.0.6
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -8813,6 +8856,8 @@ snapshots:
node-addon-api@7.1.1: {} node-addon-api@7.1.1: {}
node-addon-api@8.9.1: {}
node-exports-info@1.6.2: node-exports-info@1.6.2:
dependencies: dependencies:
array.prototype.flatmap: 1.3.3 array.prototype.flatmap: 1.3.3
@@ -8822,6 +8867,8 @@ snapshots:
node-fetch-native@1.6.7: {} node-fetch-native@1.6.7: {}
node-gyp-build@4.8.4: {}
node-releases@2.0.51: {} node-releases@2.0.51: {}
nypm@0.6.9: nypm@0.6.9:
@@ -1,15 +1,39 @@
ALTER TABLE "communication_rooms" ALTER TABLE "communication_rooms" ADD COLUMN "audienceType" TEXT NOT NULL DEFAULT 'direct';
ADD COLUMN "audienceType" TEXT NOT NULL DEFAULT 'direct', ALTER TABLE "communication_rooms" ADD COLUMN "classGroupId" TEXT;
ADD COLUMN "classGroupId" TEXT, ALTER TABLE "communication_rooms" ADD COLUMN "escalationEligibleAt" TIMESTAMP(3);
ADD COLUMN "escalationEligibleAt" TIMESTAMP(3), ALTER TABLE "communication_rooms" ADD COLUMN "escalatedAt" TIMESTAMP(3);
ADD COLUMN "escalatedAt" TIMESTAMP(3), ALTER TABLE "communication_rooms" ADD COLUMN "resolvedAt" TIMESTAMP(3);
ADD COLUMN "resolvedAt" TIMESTAMP(3), ALTER TABLE "communication_rooms" ADD COLUMN "resolutionStatus" TEXT NOT NULL DEFAULT 'open';
ADD COLUMN "resolutionStatus" TEXT NOT NULL DEFAULT 'open';
ALTER TABLE "communication_rooms" PRAGMA foreign_keys=OFF;
ADD CONSTRAINT "communication_rooms_classGroupId_fkey" CREATE TABLE "new_communication_rooms" (
FOREIGN KEY ("classGroupId") REFERENCES "ClassGroup"("id") "id" TEXT NOT NULL PRIMARY KEY,
ON DELETE SET NULL ON UPDATE CASCADE; "schoolId" TEXT NOT NULL,
"studentId" TEXT NOT NULL,
"teacherId" TEXT NOT NULL,
"roomType" TEXT NOT NULL DEFAULT 'chat',
"audienceType" TEXT NOT NULL DEFAULT 'direct',
"classGroupId" TEXT,
"status" TEXT NOT NULL DEFAULT 'requested',
"requestedBy" TEXT NOT NULL,
"escalationEligibleAt" DATETIME,
"escalatedAt" DATETIME,
"resolvedAt" DATETIME,
"resolutionStatus" TEXT NOT NULL DEFAULT 'open',
"acceptedAt" DATETIME,
"closedAt" DATETIME,
"closeReason" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "communication_rooms_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "communication_rooms_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "communication_rooms_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "communication_rooms_classGroupId_fkey" FOREIGN KEY ("classGroupId") REFERENCES "ClassGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_communication_rooms" ("id", "schoolId", "studentId", "teacherId", "roomType", "audienceType", "classGroupId", "status", "requestedBy", "escalationEligibleAt", "escalatedAt", "resolvedAt", "resolutionStatus", "acceptedAt", "closedAt", "closeReason", "createdAt", "updatedAt") SELECT "id", "schoolId", "studentId", "teacherId", "roomType", "audienceType", "classGroupId", "status", "requestedBy", "escalationEligibleAt", "escalatedAt", "resolvedAt", "resolutionStatus", "acceptedAt", "closedAt", "closeReason", "createdAt", "updatedAt" FROM "communication_rooms";
DROP TABLE "communication_rooms";
ALTER TABLE "new_communication_rooms" RENAME TO "communication_rooms";
PRAGMA foreign_keys=ON;
CREATE INDEX "communication_rooms_classGroupId_idx" ON "communication_rooms"("classGroupId"); CREATE INDEX "communication_rooms_classGroupId_idx" ON "communication_rooms"("classGroupId");
CREATE INDEX "communication_rooms_escalationEligibleAt_idx" ON "communication_rooms"("escalationEligibleAt"); CREATE INDEX "communication_rooms_escalationEligibleAt_idx" ON "communication_rooms"("escalationEligibleAt");
@@ -1,21 +1,49 @@
ALTER TABLE "illness_reports" ALTER TABLE "illness_reports" ADD COLUMN "leaveType" TEXT NOT NULL DEFAULT 'illness';
ADD COLUMN "leaveType" TEXT NOT NULL DEFAULT 'illness', ALTER TABLE "illness_reports" ADD COLUMN "teacherNotifiedAt" TIMESTAMP(3);
ADD COLUMN "teacherNotifiedAt" TIMESTAMP(3), ALTER TABLE "illness_reports" ADD COLUMN "adminApprovalStatus" TEXT NOT NULL DEFAULT 'pending';
ADD COLUMN "adminApprovalStatus" TEXT NOT NULL DEFAULT 'pending', ALTER TABLE "illness_reports" ADD COLUMN "adminApprovedBy" TEXT;
ADD COLUMN "adminApprovedBy" TEXT, ALTER TABLE "illness_reports" ADD COLUMN "adminApprovedAt" TIMESTAMP(3);
ADD COLUMN "adminApprovedAt" TIMESTAMP(3), ALTER TABLE "illness_reports" ADD COLUMN "adminNotes" TEXT;
ADD COLUMN "adminNotes" TEXT, ALTER TABLE "illness_reports" ADD COLUMN "calendarEventId" TEXT;
ADD COLUMN "calendarEventId" TEXT;
ALTER TABLE "illness_reports" PRAGMA foreign_keys=OFF;
ADD CONSTRAINT "illness_reports_adminApprovedBy_fkey" CREATE TABLE "new_illness_reports" (
FOREIGN KEY ("adminApprovedBy") REFERENCES "User"("id") "id" TEXT NOT NULL PRIMARY KEY,
ON DELETE SET NULL ON UPDATE CASCADE; "schoolId" TEXT NOT NULL,
"studentId" TEXT NOT NULL,
ALTER TABLE "illness_reports" "reportedBy" TEXT NOT NULL,
ADD CONSTRAINT "illness_reports_calendarEventId_fkey" "reporterType" TEXT NOT NULL,
FOREIGN KEY ("calendarEventId") REFERENCES "CalendarEvent"("id") "reason" TEXT NOT NULL,
ON DELETE SET NULL ON UPDATE CASCADE; "description" TEXT,
"startDate" DATETIME NOT NULL,
"endDate" DATETIME,
"documentUrl" TEXT,
"leaveType" TEXT NOT NULL DEFAULT 'illness',
"status" TEXT NOT NULL DEFAULT 'pending',
"parentApprovalStatus" TEXT NOT NULL DEFAULT 'pending',
"parentApprovedBy" TEXT,
"parentApprovedAt" DATETIME,
"isVisibleToTeacher" BOOLEAN NOT NULL DEFAULT false,
"isVisibleToAdmin" BOOLEAN NOT NULL DEFAULT false,
"teacherNotifiedAt" DATETIME,
"adminApprovalStatus" TEXT NOT NULL DEFAULT 'pending',
"adminApprovedBy" TEXT,
"adminApprovedAt" DATETIME,
"adminNotes" TEXT,
"calendarEventId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "illness_reports_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "illness_reports_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "illness_reports_reportedBy_fkey" FOREIGN KEY ("reportedBy") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "illness_reports_parentApprovedBy_fkey" FOREIGN KEY ("parentApprovedBy") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "illness_reports_adminApprovedBy_fkey" FOREIGN KEY ("adminApprovedBy") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "illness_reports_calendarEventId_fkey" FOREIGN KEY ("calendarEventId") REFERENCES "CalendarEvent" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_illness_reports" ("id", "schoolId", "studentId", "reportedBy", "reporterType", "reason", "description", "startDate", "endDate", "documentUrl", "leaveType", "status", "parentApprovalStatus", "parentApprovedBy", "parentApprovedAt", "isVisibleToTeacher", "isVisibleToAdmin", "teacherNotifiedAt", "adminApprovalStatus", "adminApprovedBy", "adminApprovedAt", "adminNotes", "calendarEventId", "createdAt", "updatedAt") SELECT "id", "schoolId", "studentId", "reportedBy", "reporterType", "reason", "description", "startDate", "endDate", "documentUrl", "leaveType", "status", "parentApprovalStatus", "parentApprovedBy", "parentApprovedAt", "isVisibleToTeacher", "isVisibleToAdmin", "teacherNotifiedAt", "adminApprovalStatus", "adminApprovedBy", "adminApprovedAt", "adminNotes", "calendarEventId", "createdAt", "updatedAt" FROM "illness_reports";
DROP TABLE "illness_reports";
ALTER TABLE "new_illness_reports" RENAME TO "illness_reports";
PRAGMA foreign_keys=ON;
CREATE UNIQUE INDEX "illness_reports_calendarEventId_key" ON "illness_reports"("calendarEventId"); CREATE UNIQUE INDEX "illness_reports_calendarEventId_key" ON "illness_reports"("calendarEventId");
CREATE INDEX "illness_reports_adminApprovalStatus_idx" ON "illness_reports"("adminApprovalStatus"); CREATE INDEX "illness_reports_adminApprovalStatus_idx" ON "illness_reports"("adminApprovalStatus");
@@ -1,20 +1,38 @@
ALTER TABLE "teacher_absences" ALTER TABLE "teacher_absences" ADD COLUMN "approvalStatus" TEXT NOT NULL DEFAULT 'pending';
ADD COLUMN "approvalStatus" TEXT NOT NULL DEFAULT 'pending', ALTER TABLE "teacher_absences" ADD COLUMN "approvedBy" TEXT;
ADD COLUMN "approvedBy" TEXT, ALTER TABLE "teacher_absences" ADD COLUMN "approvedAt" TIMESTAMP(3);
ADD COLUMN "approvedAt" TIMESTAMP(3), ALTER TABLE "teacher_absences" ADD COLUMN "privateAdminNotes" TEXT;
ADD COLUMN "privateAdminNotes" TEXT, ALTER TABLE "teacher_absences" ADD COLUMN "documentUrl" TEXT;
ADD COLUMN "documentUrl" TEXT, ALTER TABLE "teacher_absences" ADD COLUMN "calendarEventId" TEXT;
ADD COLUMN "calendarEventId" TEXT;
ALTER TABLE "teacher_absences" PRAGMA foreign_keys=OFF;
ADD CONSTRAINT "teacher_absences_approvedBy_fkey" CREATE TABLE "new_teacher_absences" (
FOREIGN KEY ("approvedBy") REFERENCES "User"("id") "id" TEXT NOT NULL PRIMARY KEY,
ON DELETE SET NULL ON UPDATE CASCADE; "schoolId" TEXT NOT NULL,
"teacherId" TEXT NOT NULL,
ALTER TABLE "teacher_absences" "startDate" DATETIME NOT NULL,
ADD CONSTRAINT "teacher_absences_calendarEventId_fkey" "endDate" DATETIME NOT NULL,
FOREIGN KEY ("calendarEventId") REFERENCES "CalendarEvent"("id") "type" TEXT NOT NULL,
ON DELETE SET NULL ON UPDATE CASCADE; "reason" TEXT,
"status" TEXT NOT NULL DEFAULT 'reported',
"notes" TEXT,
"approvalStatus" TEXT NOT NULL DEFAULT 'pending',
"approvedBy" TEXT,
"approvedAt" DATETIME,
"privateAdminNotes" TEXT,
"documentUrl" TEXT,
"calendarEventId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "teacher_absences_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "teacher_absences_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "teacher_absences_approvedBy_fkey" FOREIGN KEY ("approvedBy") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "teacher_absences_calendarEventId_fkey" FOREIGN KEY ("calendarEventId") REFERENCES "CalendarEvent" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_teacher_absences" ("id", "schoolId", "teacherId", "startDate", "endDate", "type", "reason", "status", "notes", "approvalStatus", "approvedBy", "approvedAt", "privateAdminNotes", "documentUrl", "calendarEventId", "createdAt", "updatedAt") SELECT "id", "schoolId", "teacherId", "startDate", "endDate", "type", "reason", "status", "notes", "approvalStatus", "approvedBy", "approvedAt", "privateAdminNotes", "documentUrl", "calendarEventId", "createdAt", "updatedAt" FROM "teacher_absences";
DROP TABLE "teacher_absences";
ALTER TABLE "new_teacher_absences" RENAME TO "teacher_absences";
PRAGMA foreign_keys=ON;
CREATE UNIQUE INDEX "teacher_absences_calendarEventId_key" ON "teacher_absences"("calendarEventId"); CREATE UNIQUE INDEX "teacher_absences_calendarEventId_key" ON "teacher_absences"("calendarEventId");
CREATE INDEX "teacher_absences_approvalStatus_idx" ON "teacher_absences"("approvalStatus"); CREATE INDEX "teacher_absences_approvalStatus_idx" ON "teacher_absences"("approvalStatus");
@@ -0,0 +1,15 @@
ALTER TABLE "User" ADD COLUMN "emailVerifiedAt" DATETIME;
CREATE TABLE "PasswordResetToken" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"usedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "PasswordResetToken_tokenHash_key" ON "PasswordResetToken"("tokenHash");
CREATE INDEX "PasswordResetToken_userId_idx" ON "PasswordResetToken"("userId");
CREATE INDEX "PasswordResetToken_expiresAt_idx" ON "PasswordResetToken"("expiresAt");
+16
View File
@@ -148,6 +148,7 @@ model User {
role String @default("TEACHER") // TEACHER, SCHOOL_ADMIN, SUPER_ADMIN, STUDENT, PARENT, VICE_PRINCIPAL, DPO role String @default("TEACHER") // TEACHER, SCHOOL_ADMIN, SUPER_ADMIN, STUDENT, PARENT, VICE_PRINCIPAL, DPO
locale String @default("de") locale String @default("de")
twoFactorSecret String? twoFactorSecret String?
emailVerifiedAt DateTime?
isDemo Boolean @default(false) isDemo Boolean @default(false)
isTeacher Boolean @default(false) // civil-servant status for Personalrat rights isTeacher Boolean @default(false) // civil-servant status for Personalrat rights
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -243,6 +244,21 @@ model User {
// Module J widgets relations // Module J widgets relations
dashboardWidgets DashboardWidget[] dashboardWidgets DashboardWidget[]
dashboardLayouts DashboardLayout[] dashboardLayouts DashboardLayout[]
passwordResetTokens PasswordResetToken[]
}
model PasswordResetToken {
id String @id @default(cuid())
userId String
tokenHash String @unique
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
} }
model SchoolYear { model SchoolYear {
+7 -2
View File
@@ -1,5 +1,5 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs' import argon2 from 'argon2'
const prisma = new PrismaClient() const prisma = new PrismaClient()
const password = 'Demo2025!' const password = 'Demo2025!'
@@ -13,7 +13,12 @@ const demoUsers = [
] ]
async function main() { async function main() {
const passwordHash = await bcrypt.hash(password, 10) const passwordHash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19 * 1024,
timeCost: 2,
parallelism: 1,
})
for (const user of demoUsers) { for (const user of demoUsers) {
await prisma.user.upsert({ await prisma.user.upsert({
+7 -2
View File
@@ -6,7 +6,7 @@
*/ */
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs' import argon2 from 'argon2'
const prisma = new PrismaClient() const prisma = new PrismaClient()
@@ -96,7 +96,12 @@ async function main() {
// ─── 3. Demo Users ────────────────────────────────────────────────── // ─── 3. Demo Users ──────────────────────────────────────────────────
console.log(' 👩‍🏫 Creating demo users...') console.log(' 👩‍🏫 Creating demo users...')
const passwordHash = await bcrypt.hash('Demo2025!', 10) const passwordHash = await argon2.hash('Demo2025!', {
type: argon2.argon2id,
memoryCost: 19 * 1024,
timeCost: 2,
parallelism: 1,
})
const teacher = await prisma.user.create({ const teacher = await prisma.user.create({
data: { data: {
schoolId: school.id, schoolId: school.id,
+32
View File
@@ -0,0 +1,32 @@
import { access, mkdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl?.startsWith('file:')) {
throw new Error('DATABASE_URL must use a SQLite file: URL');
}
const source = path.resolve(databaseUrl.slice('file:'.length));
const backupDirectory = path.resolve(process.env.BACKUP_PATH || path.join(process.cwd(), 'data', 'backups'));
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const destination = path.join(backupDirectory, `schulos-sqlite-${timestamp}.db`);
await access(source);
await mkdir(backupDirectory, { recursive: true });
const result = spawnSync('sqlite3', [source, `.backup '${destination.replaceAll("'", "''")}'`], {
encoding: 'utf8',
});
if (result.error || result.status !== 0) {
throw result.error ?? new Error(result.stderr || 'SQLite online backup failed');
}
const integrity = spawnSync('sqlite3', [destination, 'PRAGMA integrity_check;'], { encoding: 'utf8' });
if (integrity.error || integrity.status !== 0 || integrity.stdout.trim() !== 'ok') {
throw integrity.error ?? new Error(`Backup integrity check failed: ${integrity.stdout || integrity.stderr}`);
}
const backupStat = await stat(destination);
console.log(JSON.stringify({ backup: destination, bytes: backupStat.size, integrity: 'ok' }));
+23
View File
@@ -0,0 +1,23 @@
import { access, mkdir } from 'node:fs/promises';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl?.startsWith('file:')) {
throw new Error('DATABASE_URL must use a SQLite file: URL');
}
const databasePath = path.resolve(databaseUrl.slice('file:'.length));
await mkdir(path.dirname(databasePath), { recursive: true });
const result = spawnSync('sqlite3', [databasePath, 'PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON; PRAGMA wal_checkpoint(TRUNCATE);'], {
encoding: 'utf8',
});
if (result.error || result.status !== 0) {
throw result.error ?? new Error(result.stderr || 'Unable to configure SQLite WAL mode');
}
await access(databasePath);
console.log(`SQLite WAL configured: ${databasePath}`);
+68
View File
@@ -4,6 +4,7 @@ import { db } from '@/lib/db';
import { import {
hashPassword, hashPassword,
verifyPassword, verifyPassword,
needsPasswordRehash,
createSession, createSession,
getSession, getSession,
clearSession, clearSession,
@@ -12,6 +13,7 @@ import {
getSessionCookieOptions, getSessionCookieOptions,
} from '@/lib/auth'; } from '@/lib/auth';
import { withRateLimit, checkRateLimit, getRateLimitKey, getRateLimitHeaders, createRateLimitResponse } from '@/lib/rate-limit'; import { withRateLimit, checkRateLimit, getRateLimitKey, getRateLimitHeaders, createRateLimitResponse } from '@/lib/rate-limit';
import { createPasswordResetToken, hashResetToken, passwordResetExpiry } from '@/lib/password-reset';
const loginSchema = z.object({ const loginSchema = z.object({
email: z.string().email(), email: z.string().email(),
@@ -26,11 +28,69 @@ const registerSchema = z.object({
schoolId: z.string().optional(), schoolId: z.string().optional(),
}); });
const requestPasswordResetSchema = z.object({
email: z.string().email(),
});
const resetPasswordSchema = z.object({
token: z.string().min(40),
password: z.string().min(12),
});
export const POST = withRateLimit(async function POST(request: Request) { export const POST = withRateLimit(async function POST(request: Request) {
try { try {
const body = await request.json(); const body = await request.json();
const { action } = body; const { action } = body;
if (action === 'request-password-reset') {
const parsed = requestPasswordResetSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: 'Validation failed' }, { status: 400 });
const key = `password-reset:${getRateLimitKey(request)}`;
const result = checkRateLimit(key, 5, 60 * 60 * 1000);
if (!result.allowed) {
const response = createRateLimitResponse(result);
return NextResponse.json(response.body, { status: response.status, headers: response.headers });
}
const user = await db.user.findFirst({ where: { email: parsed.data.email, deletedAt: null } });
let developmentToken: string | undefined;
if (user) {
await db.passwordResetToken.deleteMany({ where: { userId: user.id, usedAt: null } });
const { token, tokenHash } = createPasswordResetToken();
await db.passwordResetToken.create({
data: { userId: user.id, tokenHash, expiresAt: passwordResetExpiry() },
});
if (process.env.NODE_ENV !== 'production' || process.env.AUTH_SHOW_RESET_TOKEN === 'true') {
developmentToken = token;
}
}
return NextResponse.json({
message: 'If the account exists, reset instructions have been prepared.',
...(developmentToken ? { developmentToken } : {}),
});
}
if (action === 'reset-password') {
const parsed = resetPasswordSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: 'Validation failed' }, { status: 400 });
const reset = await db.passwordResetToken.findFirst({
where: { tokenHash: hashResetToken(parsed.data.token), usedAt: null, expiresAt: { gt: new Date() } },
include: { user: true },
});
if (!reset || reset.user.deletedAt) {
return NextResponse.json({ error: 'Invalid or expired reset token' }, { status: 400 });
}
await db.$transaction([
db.user.update({ where: { id: reset.userId }, data: { passwordHash: await hashPassword(parsed.data.password) } }),
db.passwordResetToken.update({ where: { id: reset.id }, data: { usedAt: new Date() } }),
]);
return NextResponse.json({ message: 'Password reset complete' });
}
// ── Login ── // ── Login ──
if (action === 'login') { if (action === 'login') {
const parsed = loginSchema.safeParse(body); const parsed = loginSchema.safeParse(body);
@@ -62,6 +122,13 @@ export const POST = withRateLimit(async function POST(request: Request) {
); );
} }
if (needsPasswordRehash(user.passwordHash)) {
await db.user.update({
where: { id: user.id },
data: { passwordHash: await hashPassword(password) },
});
}
const secure = isSecureRequest(request); const secure = isSecureRequest(request);
await createSession(user.id, secure); await createSession(user.id, secure);
@@ -112,6 +179,7 @@ export const POST = withRateLimit(async function POST(request: Request) {
lastName, lastName,
schoolId: schoolId ?? null, schoolId: schoolId ?? null,
role: 'TEACHER', role: 'TEACHER',
emailVerifiedAt: process.env.AUTH_REQUIRE_EMAIL_VERIFICATION === 'true' ? null : new Date(),
}, },
}); });
+13 -4
View File
@@ -2,6 +2,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { withRateLimit } from '@/lib/rate-limit'; import { withRateLimit } from '@/lib/rate-limit';
import { deleteStorageFile, readStorageFile, writeStorageFile } from '@/lib/storage';
// GET /api/backup — List backups for a school // GET /api/backup — List backups for a school
export async function GET(req: Request) { export async function GET(req: Request) {
@@ -41,9 +42,12 @@ export const POST = withRateLimit(async function POST(req: Request) {
if (!backup || backup.status !== 'completed') { if (!backup || backup.status !== 'completed') {
return NextResponse.json({ error: 'Backup not found or not completed' }, { status: 404 }); return NextResponse.json({ error: 'Backup not found or not completed' }, { status: 404 });
} }
// In a real implementation, we would restore the DB from the backup JSON const content = await readStorageFile('backups', `${backup.schoolId}/${backup.filename}`);
// For now, we just mark the restore as acknowledged return NextResponse.json({
return NextResponse.json({ message: 'Backup restore initiated', backup }); message: 'Backup file verified. Restore must run in a disposable maintenance environment.',
backup,
bytes: content.byteLength,
});
} }
// Create a new backup — export entire DB as JSON // Create a new backup — export entire DB as JSON
@@ -106,6 +110,7 @@ export const POST = withRateLimit(async function POST(req: Request) {
const jsonStr = JSON.stringify(backupData, null, 2); const jsonStr = JSON.stringify(backupData, null, 2);
const size = Buffer.byteLength(jsonStr, 'utf-8'); const size = Buffer.byteLength(jsonStr, 'utf-8');
await writeStorageFile('backups', `${schoolId}/${filename}`, jsonStr);
const backup = await db.backup.create({ const backup = await db.backup.create({
data: { data: {
@@ -120,7 +125,6 @@ export const POST = withRateLimit(async function POST(req: Request) {
return NextResponse.json({ return NextResponse.json({
...backup, ...backup,
data: backupData, // Include the actual backup data for download
}); });
} catch (error) { } catch (error) {
console.error('Backup create error:', error); console.error('Backup create error:', error);
@@ -151,6 +155,11 @@ export async function DELETE(req: NextRequest) {
if (!id) { if (!id) {
return NextResponse.json({ error: 'id is required' }, { status: 400 }); return NextResponse.json({ error: 'id is required' }, { status: 400 });
} }
const backup = await db.backup.findUnique({ where: { id } });
if (!backup) {
return NextResponse.json({ error: 'Backup not found' }, { status: 404 });
}
await deleteStorageFile('backups', `${backup.schoolId}/${backup.filename}`);
await db.backup.delete({ where: { id } }); await db.backup.delete({ where: { id } });
return NextResponse.json({ message: 'Backup deleted' }); return NextResponse.json({ message: 'Backup deleted' });
} catch (error) { } catch (error) {
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, test } from 'bun:test';
import bcrypt from 'bcryptjs';
import { hashPassword, needsPasswordRehash, verifyPassword } from './auth';
describe('password hashing', () => {
test('creates and verifies Argon2id hashes', async () => {
const hash = await hashPassword('correct horse battery staple');
expect(hash.startsWith('$argon2id$')).toBeTrue();
expect(await verifyPassword('correct horse battery staple', hash)).toBeTrue();
expect(await verifyPassword('incorrect password', hash)).toBeFalse();
expect(needsPasswordRehash(hash)).toBeFalse();
});
test('verifies legacy bcrypt hashes for rehash-on-login migration', async () => {
const legacyHash = await bcrypt.hash('legacy-password', 10);
expect(await verifyPassword('legacy-password', legacyHash)).toBeTrue();
expect(needsPasswordRehash(legacyHash)).toBeTrue();
});
});
+21 -3
View File
@@ -1,10 +1,22 @@
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import argon2 from 'argon2';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
export const SESSION_COOKIE_NAME = 'ct_session'; export const SESSION_COOKIE_NAME = 'ct_session';
export const SESSION_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days const DEFAULT_SESSION_MAX_AGE = 60 * 60 * 24 * 7;
const parsedSessionMaxAge = Number.parseInt(process.env.SESSION_MAX_AGE_SECONDS ?? '', 10);
export const SESSION_COOKIE_MAX_AGE = Number.isFinite(parsedSessionMaxAge) && parsedSessionMaxAge >= 60
? parsedSessionMaxAge
: DEFAULT_SESSION_MAX_AGE;
const ARGON2_OPTIONS = {
type: argon2.argon2id,
memoryCost: 19 * 1024,
timeCost: 2,
parallelism: 1,
} as const;
/** /**
* Determine whether the Secure flag should be set on cookies. * Determine whether the Secure flag should be set on cookies.
@@ -32,17 +44,23 @@ export function getSessionCookieOptions(secure: boolean) {
} }
export async function hashPassword(password: string): Promise<string> { export async function hashPassword(password: string): Promise<string> {
const salt = await bcrypt.genSalt(10); return argon2.hash(password, { ...ARGON2_OPTIONS, raw: false });
return bcrypt.hash(password, salt);
} }
export async function verifyPassword( export async function verifyPassword(
password: string, password: string,
hash: string hash: string
): Promise<boolean> { ): Promise<boolean> {
if (hash.startsWith('$argon2')) {
return argon2.verify(hash, password);
}
return bcrypt.compare(password, hash); return bcrypt.compare(password, hash);
} }
export function needsPasswordRehash(hash: string): boolean {
return !hash.startsWith('$argon2');
}
export async function createSession(userId: string, secure?: boolean): Promise<void> { export async function createSession(userId: string, secure?: boolean): Promise<void> {
const cookieStore = await cookies(); const cookieStore = await cookies();
const cookieSecure = secure ?? (process.env.NODE_ENV === 'production'); const cookieSecure = secure ?? (process.env.NODE_ENV === 'production');
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, test } from 'bun:test';
import { isCsrfProtectedRequest, isTrustedRequestOrigin } from './csrf';
describe('CSRF origin protection', () => {
test('allows safe methods without origin headers', () => {
const request = new Request('https://school.example/api/students');
expect(isCsrfProtectedRequest(request)).toBeFalse();
expect(isTrustedRequestOrigin(request)).toBeTrue();
});
test('allows mutations from the request origin', () => {
const request = new Request('https://school.example/api/students', {
method: 'POST',
headers: { origin: 'https://school.example' },
});
expect(isCsrfProtectedRequest(request)).toBeTrue();
expect(isTrustedRequestOrigin(request)).toBeTrue();
});
test('rejects cross-origin mutation requests', () => {
const request = new Request('https://school.example/api/students', {
method: 'DELETE',
headers: { origin: 'https://attacker.example' },
});
expect(isTrustedRequestOrigin(request)).toBeFalse();
});
});
+41
View File
@@ -0,0 +1,41 @@
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
function normalizeOrigin(value: string): string | null {
try {
return new URL(value).origin;
} catch {
return null;
}
}
function configuredOrigins(): Set<string> {
return new Set(
(process.env.NEXT_PUBLIC_APP_URL ?? '')
.split(',')
.map((origin) => normalizeOrigin(origin.trim()))
.filter((origin): origin is string => Boolean(origin))
);
}
export function isCsrfProtectedRequest(request: Request): boolean {
return !SAFE_METHODS.has(request.method.toUpperCase())
&& new URL(request.url).pathname.startsWith('/api/');
}
export function isTrustedRequestOrigin(request: Request): boolean {
if (!isCsrfProtectedRequest(request)) return true;
const requestOrigin = normalizeOrigin(request.url);
const origin = normalizeOrigin(request.headers.get('origin') ?? '');
const referer = normalizeOrigin(request.headers.get('referer') ?? '');
const allowedOrigins = configuredOrigins();
if (requestOrigin) allowedOrigins.add(requestOrigin);
if (process.env.NODE_ENV !== 'production') {
allowedOrigins.add('http://localhost:3000');
allowedOrigins.add('http://127.0.0.1:3000');
}
const suppliedOrigin = origin ?? referer;
return suppliedOrigin !== null && allowedOrigins.has(suppliedOrigin);
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, test } from 'bun:test';
import { createPasswordResetToken, hashResetToken, passwordResetExpiry } from './password-reset';
describe('password reset tokens', () => {
test('creates opaque tokens and stable SHA-256 lookup hashes', () => {
const { token, tokenHash } = createPasswordResetToken();
expect(token.length).toBeGreaterThan(30);
expect(tokenHash).toBe(hashResetToken(token));
expect(tokenHash).not.toBe(token);
});
test('uses a one-hour default expiry', () => {
const now = new Date('2026-08-02T10:00:00.000Z');
expect(passwordResetExpiry(now).toISOString()).toBe('2026-08-02T11:00:00.000Z');
});
});
+21
View File
@@ -0,0 +1,21 @@
import { createHash, randomBytes } from 'node:crypto';
const TOKEN_BYTES = 32;
const DEFAULT_TOKEN_TTL_SECONDS = 60 * 60;
export function hashResetToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export function createPasswordResetToken(): { token: string; tokenHash: string } {
const token = randomBytes(TOKEN_BYTES).toString('base64url');
return { token, tokenHash: hashResetToken(token) };
}
export function passwordResetExpiry(now = new Date()): Date {
const configuredTtl = Number.parseInt(process.env.PASSWORD_RESET_TTL_SECONDS ?? '', 10);
const ttl = Number.isFinite(configuredTtl) && configuredTtl >= 300
? configuredTtl
: DEFAULT_TOKEN_TTL_SECONDS;
return new Date(now.getTime() + ttl * 1000);
}
+3
View File
@@ -26,6 +26,9 @@ export const RATE_LIMITS = {
// Auth POST endpoints: 30 requests per minute (allows retries & multiple tabs) // Auth POST endpoints: 30 requests per minute (allows retries & multiple tabs)
auth: { limit: 30, windowMs: 60 * 1000 }, auth: { limit: 30, windowMs: 60 * 1000 },
// Password reset requests: 5 requests per hour per source IP.
passwordReset: { limit: 5, windowMs: 60 * 60 * 1000 },
// Auth GET endpoint: 120 requests per minute (high frequency, low cost) // Auth GET endpoint: 120 requests per minute (high frequency, low cost)
authGet: { limit: 120, windowMs: 60 * 1000 }, authGet: { limit: 120, windowMs: 60 * 1000 },
+31
View File
@@ -0,0 +1,31 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { deleteStorageFile, readStorageFile, storagePath, writeStorageFile } from './storage';
const originalBackupPath = process.env.BACKUP_PATH;
const tempDirectories: string[] = [];
afterEach(async () => {
process.env.BACKUP_PATH = originalBackupPath;
await Promise.all(tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
describe('local storage', () => {
test('writes, reads, and deletes files inside the configured storage area', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'schulos-storage-'));
tempDirectories.push(directory);
process.env.BACKUP_PATH = directory;
await writeStorageFile('backups', 'school-1/backup.json', '{"version":1}');
expect((await readStorageFile('backups', 'school-1/backup.json')).toString()).toBe('{"version":1}');
await deleteStorageFile('backups', 'school-1/backup.json');
expect(storagePath('backups', 'school-1/backup.json')).toContain(directory);
});
test('rejects path traversal outside storage root', () => {
expect(() => storagePath('backups', '../../outside.json')).toThrow('Storage path must remain within its configured area');
});
});
+50
View File
@@ -0,0 +1,50 @@
import path from 'node:path';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
export type StorageArea = 'uploads' | 'exports' | 'backups';
const DEFAULT_STORAGE_ROOT = path.join(process.cwd(), 'data');
function getAreaRoot(area: StorageArea): string {
const configured = area === 'uploads'
? process.env.STORAGE_PATH
: area === 'exports'
? process.env.EXPORT_PATH
: process.env.BACKUP_PATH;
return path.resolve(configured || path.join(DEFAULT_STORAGE_ROOT, area));
}
function getSafePath(area: StorageArea, relativePath: string): string {
const root = getAreaRoot(area);
const target = path.resolve(root, relativePath);
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
throw new Error('Storage path must remain within its configured area');
}
return target;
}
export function storagePath(area: StorageArea, relativePath: string): string {
return getSafePath(area, relativePath);
}
export async function writeStorageFile(
area: StorageArea,
relativePath: string,
content: string | Buffer
): Promise<string> {
const target = getSafePath(area, relativePath);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, content, { mode: 0o600 });
return target;
}
export async function readStorageFile(area: StorageArea, relativePath: string): Promise<Buffer> {
return readFile(getSafePath(area, relativePath));
}
export async function deleteStorageFile(area: StorageArea, relativePath: string): Promise<void> {
await rm(getSafePath(area, relativePath), { force: true });
}