# AGENTS.md - Development Guidelines Essential information for AI agents and developers working on this codebase. ## Project Overview Web application for "Harmonie de Montpellier-Jacou" (music band) to manage and share sheet music (partitions). - **Frontend**: SvelteKit (Svelte 5, runes) + Tailwind CSS in `partitions/` - **Backend**: PHP 8.5 REST API in `api/` (no framework) - **Storage**: flat files under `legacy/Scores/` (no database) - **Auth**: JWT (HS256), see `api/lib/Auth.php` - **Production**: `https://partitions.c.nadal-fr.com` (front) + `https://ohmj-api.c.nadal-fr.com` (API) There is **no MySQL** and **no Vue.js** in the active codebase. The old Vue.js 2 / MySQL site is archived in `legacy/` and must not be modified. ## Stack & Versions - Node.js 26 / npm 11 - Svelte 5, SvelteKit 2, `@sveltejs/adapter-static`, Vite 5, Tailwind 3 - PHP 8.5 (CLI built-in server in development) ## Dependencies ### Frontend (`partitions/package.json`) Runtime: | Package | Version | |---|---| | `axios` | ^1.6.0 | | `pdfjs-dist` | ^4.0.379 | Dev: | Package | Version | |---|---| | `svelte` | ^5.0.0 | | `@sveltejs/kit` | ^2.0.0 | | `@sveltejs/adapter-static` | ^3.0.0 | | `@sveltejs/vite-plugin-svelte` | ^4.0.0 | | `vite` | ^5.0.0 | | `typescript` | ^5.0.0 | | `svelte-check` | ^4.0.0 | | `tailwindcss` | ^3.4.0 | | `autoprefixer` | ^10.4.16 | | `postcss` | ^8.4.32 | Install: `cd partitions && npm install`. ### Backend (`api/composer.json`) | Package | Version | |---|---| | `setasign/fpdi` | ^2.6 (import de pages PDF) | | `setasign/fpdf` | ^1.8 (base PDF) | | `tecnickcom/tcpdf` | ^6.10 | Install: `cd api && php composer.phar install` (le dossier `vendor/` est déjà présent). PHP extensions requises (via `composer.lock`) : `gd`, `zlib`, `curl`. Déjà fournies ou activées avec les paquets Arch `php` + `php-gd`. ### Tooling de développement - `xdebug` (paquet Arch `extra/xdebug`) — optionnel, utilisé pour mesurer la couverture de code du backend (`xdebug.mode=coverage`). > PHP 8.5 note: nullable parameters MUST use explicit `?type` syntax > (e.g. `?string $x = null`). Implicit nullable is deprecated and pollutes API output. ## Build & Run Commands ### Backend (PHP) ```bash cd api php -c php-upload.ini -S localhost:8000 router.php ``` - `-c php-upload.ini` is **required** so PDF uploads work (`upload_max_filesize`/`post_max_size` = 64M). `ini_set()` cannot change those directives at runtime. - API base URL: `http://localhost:8000` (paths have **no** `/api` prefix in the client). - Copy `api/.env.example` to `api/.env` and set a strong `JWT_SECRET`. ### Frontend (SvelteKit) ```bash cd partitions npm install npm run dev # http://localhost:5173 npm run build # outputs to partitions/build npm run check # svelte-check (0 errors / 0 warnings expected) npm run preview ``` - `frontend` is a symlink to `partitions/build` (the deployed static site). - `VITE_API_URL` (build/dev env) selects the API base URL; falls back to `localhost:8000` (dev) or the production URL. - No ESLint / test framework is configured for the frontend. ### Deploy ```bash ./deploy/deploy.sh ``` - Do NOT include `legacy/` or `api/tests.php` in the deploy archive. - `api/.env` (with `JWT_SECRET`) is part of the deployment. - The frontend build must be present in `frontend/`. - Production: front `https://partitions.c.nadal-fr.com`, API `https://ohmj-api.c.nadal-fr.com`. - **Cache**: hashed assets under `_app/immutable/` are immutable (1y); the SPA shell `index.html` MUST be served with `Cache-Control: no-cache` (see `partitions/nginx.conf`). Otherwise clients keep a stale shell referencing hashed chunks removed by the new deploy, causing errors on some routes (e.g. the PDF viewer). The front is served by the `nginx` container (Caddy only `reverse_proxy`s to it and passes the headers through), so this is configured in `nginx.conf`, not in the Caddyfile. After a deploy, a hard refresh can be needed for already-cached clients. ### Testing the deploy archive (recommended) It is worth testing the produced zip, not just the source tree. `deploy.sh` generates `_builds/deploy_ohmj.zip`; verify it before uploading: ```bash # 1. Inspect the archive (no stale/test/residue files) unzip -l _builds/deploy_ohmj.zip | grep -E 'tests\.php|coverage-|composer\.phar|node_modules|legacy/' # -> must print nothing unzip -p _builds/deploy_ohmj.zip api/.env # JWT_SECRET + SCORES_PATH # 2. Extract and run the artifact end-to-end against the E2E smoke test rm -rf /tmp/deploy_test && mkdir -p /tmp/deploy_test unzip -q _builds/deploy_ohmj.zip -d /tmp/deploy_test # - API: cd /tmp/deploy_test/api # SCORES_PATH=/legacy/Scores/ php -c php-upload.ini -S localhost:8000 router.php # - front: serve /tmp/deploy_test/frontend with an SPA fallback (see below) API_URL=http://localhost:8000 FRONT_URL=http://localhost:4173 npm run test:e2e --prefix partitions # 3. Security: .env / config/users.json must NOT be served curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8000/.env # -> 401 ``` Notes: - `deploy.sh` deletes the previous zip before zipping: `zip -r` alone would **merge** into an existing archive and keep stale files from earlier deploys. - The built frontend bakes `VITE_API_URL` **and** the CSP `connect-src` (`svelte.config.js` only allows `localhost:8000` + the prod API). To test a zip locally, either reuse `localhost:8000` or rebuild the front with a permitted URL (`VITE_API_URL=http://localhost:8000 npm run build`). - The E2E smoke test accepts `API_URL` / `FRONT_URL` so it can target the extracted artifact instead of the dev servers. ## Testing ### Run everything (single command) ```bash ./test.sh ``` Runs, in order: backend tests (`api/tests.php`), backend code coverage (Xdebug, if available), frontend `npm run check` (svelte-check), and the frontend E2E smoke test. It starts the API and dev servers if needed and stops them afterwards. "Test the project" means running `./test.sh`. ### PHP API tests ```bash cd api php -c php-upload.ini -S localhost:8000 router.php & # if not already running rm -f /tmp/rate_* 2>/dev/null # clear rate limiting first php tests.php ``` - Expected: **183/183 tests passed (100%)**. - Covers: auth (login, bad credentials, missing/invalid token), scores CRUD, create-score-with-pieces, files tree, path traversal, rate limiting, upload validation, INI injection, security headers. - `tests.php` is kept for development only and MUST NOT be shipped in the deploy zip. ### Code coverage (optional, requires Xdebug) The test suite covers ~96% of `api/` non-vendor code (`Auth.php` and `router.php` at 100%, `ScoreScanner.php` +99%). The remaining uncovered lines are defensive I/O-failure branches (rate-limiter `flock`/`fopen` failures, missing `SCORES_PATH`, `OOM`/partial PHP upload errors, `createScore` rollback) that need OS-level fault injection to trigger. ```bash cd api rm -rf /tmp/ohmj_cov # Terminal 1: instrumented dev server php -d zend_extension=xdebug -d xdebug.mode=coverage \ -d auto_prepend_file="$PWD/coverage-prepend.php" \ -c php-upload.ini -S localhost:8000 router.php # Terminal 2: run tests through the instrumented server php -d zend_extension=xdebug -d xdebug.mode=coverage \ -d auto_prepend_file="$PWD/coverage-prepend.php" tests.php # Then, from the repo root: SHOW_MISSING=1 php api/coverage-report.php \ api/index.php api/router.php api/lib/Auth.php api/lib/ScoreScanner.php ``` `coverage-prepend.php` / `coverage-report.php` are dev-only and excluded from the deploy archive. ### Frontend E2E smoke test (CDP, no dependency) Requires the API (`:8000`) and the frontend dev server (`:5173`) to be running. ```bash cd partitions npm run test:e2e ``` `tests/e2e-smoke.mjs` drives the system `chromium` through the DevTools Protocol: it logs in via the API, injects the session, then visits `/scores`, a score detail, the PDF viewer and `/admin`. It asserts that every API call returns `< 400`, that the SPA stays on each route, and that the PDF is fetched with an `Authorization` header (no token in the URL). Env overrides: `API_URL`, `FRONT_URL`, `CHROME_PATH`, `LOGIN_USER`, `LOGIN_PASS`. ## Authentication - Login: `POST /login` with `{"username":"admin","password":"password"}` (development). - Token: JWT HS256 signed with `JWT_SECRET`, 1 hour expiry. - API calls: send `Authorization: Bearer `. - Token is stored in `localStorage` on the frontend and attached via an axios request interceptor. - Roles: `admin` (full access) and `user` (read-only). Admin routes live under `/admin/...`. - Frontend dev login page: http://localhost:5173 ## API Endpoints Public / all authenticated users: - `POST /login` - `GET /scores` - `GET /scores/:id` - `GET /scores/:id/instruments[?piece=N]` - `GET /pieces/:scoreId` - `GET /download/:path` (token via `Authorization` header or `?token=`) Admin only (`role === 'admin'`): - `POST /admin/scores` - `PUT /admin/scores/:id` - `PUT /admin/scores/:id/pieces` - `DELETE /admin/scores/:id` - `POST /admin/scores/:id/upload` - `GET /admin/scores/:id/files` - `DELETE /admin/scores/:id/files?path=...` ## File Organization ``` / ├── api/ # PHP REST API (active) │ ├── index.php # Router / entry point │ ├── router.php # Dev server router (+ CORS, .env loading) │ ├── lib/Auth.php # JWT auth │ ├── lib/ScoreScanner.php# File-based score index │ ├── config/users.json # Users (bcrypt hashes) │ ├── tests.php # Functional tests (dev only) │ └── .env # JWT_SECRET, SCORES_PATH ├── partitions/ # SvelteKit app (active) │ ├── src/lib/api.ts # Axios client + apiService │ ├── src/lib/stores/ # auth store │ ├── src/lib/components/ # PdfViewer │ ├── src/routes/ # pages (login, scores, admin) │ └── build/ # static output (gitignored) ├── frontend -> partitions/build # symlink used for deployment ├── deploy/deploy.sh # deploy archive builder ├── scripts/convert_final_v2.js # one-off filename converter ├── legacy/ # archived old site + Scores/ (do not modify) └── _builds/ # temporary work files (gitignored) ``` ## Data Model (files) > Full filename/directory convention: see [`NAMING.md`](NAMING.md). It is > normalized and MUST NOT be changed casually (backend + frontend depend on it). ``` legacy/Scores/////.pdf legacy/Scores//score.ini ``` `score.ini`: ```ini [info] name = Nom du morceau compositor = Compositeur ressource = https://... # optional external link [pieces] count = 1 ``` - `NUM` = score id (zero-padded, e.g. `102`). - `PIECE` = movement/piece number (1 for single-piece scores). - `INSTRUMENT` = trigram code (see below). - `VERSION` = version number. - `PARTIE` = part number. Instrument codes: `dir`, `pic`, `flu`, `cla`, `clb`, `sax`, `sat`, `sab`, `coa`, `cba`, `cor`, `trp`, `trb`, `tub`, `htb`, `bas`, `per`, `crn`, `eup`, `har`, `pia`, plus `sup`/`par` for combined parts. Admin uploads build the filename from the instrument's **ASCII** name (`instruments.ts` → `api/lib/ScoreScanner.php`), with `-` between fields (e.g. `clarinette-sib-1.pdf`). For `sup`/`par`, the **"Parties combinées"** editor builds the real filename base from one or more instruments (with key/clef/part), joined by `+` (e.g. `contrebasse-sib-clefa-1+baryton-sib-clefa-1`). See `NAMING.md`. ## Code Style ### PHP - `" # Security headers on any response curl -s -I http://localhost:8000/login -X POST \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"password"}' ``` > Security fixes applied recently: `api/router.php` no longer serves files > statically (`.env`/`config/users.json` are not reachable); `deleteScoreFile` > enforces `realpath()` containment and only deletes `.pdf`; `api/.env` and > `partitions/.env` are untracked/gitignored (rotate the secret since it remains > in git history); JWT uses base64url, requires `exp` and validates `alg`; > credentials errors are generic; the `ressource` link rejects non-http(s) URLs. > > Known findings still open: default `admin`/`password` credentials; login rate > limiter is race-prone (no `flock`) and `/api/login` bypasses it; CORS falls > back to `*` for unknown origins; `GET /admin/scores/:id` passes extra > arguments dropped by `ScoreScanner::updateScore` (so `ressource` is not > persisted); watermarking only keeps page 1 of multi-page PDFs; `createScore` > and `uploadPdf` do not validate path segments; `updateScore`/`updatePieces` > do not sanitize INI values. ## Temporary Files - Put temporary scripts and outputs in `_builds/` (gitignored). - `scripts/convert_final_v2.js` is the only committed script in `scripts/`. ## Git - Default branch: `master`. - No CI/CD configured. - Only commit when explicitly requested.