15 KiB
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 Archextra/xdebug) — optionnel, utilisé pour mesurer la couverture de code du backend (xdebug.mode=coverage).
PHP 8.5 note: nullable parameters MUST use explicit
?typesyntax (e.g.?string $x = null). Implicit nullable is deprecated and pollutes API output.
Build & Run Commands
Backend (PHP)
cd api
php -c php-upload.ini -S localhost:8000 router.php
-c php-upload.iniis 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/apiprefix in the client). - Copy
api/.env.exampletoapi/.envand set a strongJWT_SECRET.
Frontend (SvelteKit)
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
frontendis a symlink topartitions/build(the deployed static site).VITE_API_URL(build/dev env) selects the API base URL; falls back tolocalhost:8000(dev) or the production URL.- No ESLint / test framework is configured for the frontend.
Deploy
./deploy/deploy.sh
- Do NOT include
legacy/orapi/tests.phpin the deploy archive. api/.env(withJWT_SECRET) is part of the deployment.- The frontend build must be present in
frontend/. - Production: front
https://partitions.c.nadal-fr.com, APIhttps://ohmj-api.c.nadal-fr.com. - Cache: hashed assets under
_app/immutable/are immutable (1y); the SPA shellindex.htmlMUST be served withCache-Control: no-cache(seepartitions/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 thenginxcontainer (Caddy onlyreverse_proxys to it and passes the headers through), so this is configured innginx.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:
# 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=<repo>/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.shdeletes the previous zip before zipping:zip -ralone would merge into an existing archive and keep stale files from earlier deploys.- The built frontend bakes
VITE_API_URLand the CSPconnect-src(svelte.config.jsonly allowslocalhost:8000+ the prod API). To test a zip locally, either reuselocalhost:8000or rebuild the front with a permitted URL (VITE_API_URL=http://localhost:8000 npm run build). - The E2E smoke test accepts
API_URL/FRONT_URLso it can target the extracted artifact instead of the dev servers.
Testing
Run everything (single command)
./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
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: 185/185 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.phpis 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.
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.
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 /loginwith{"username":"admin","password":"password"}(development). - Token: JWT HS256 signed with
JWT_SECRET, 1 hour expiry. - API calls: send
Authorization: Bearer <token>. - Token is stored in
localStorageon the frontend and attached via an axios request interceptor. - Roles:
admin(full access) anduser(read-only). Admin routes live under/admin/.... - Frontend dev login page: http://localhost:5173
API Endpoints
Public / all authenticated users:
POST /loginGET /scoresGET /scores/:idGET /scores/:id/instruments[?piece=N]GET /pieces/:scoreIdGET /download/:path(token viaAuthorizationheader or?token=)
Admin only (role === 'admin'):
POST /admin/scoresPUT /admin/scores/:idPUT /admin/scores/:id/piecesDELETE /admin/scores/:idPOST /admin/scores/:id/uploadGET /admin/scores/:id/filesDELETE /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. It is normalized and MUST NOT be changed casually (backend + frontend depend on it).
legacy/Scores/<NUM>/<PIECE>/<INSTRUMENT>/<VERSION>/<PARTIE>.pdf
legacy/Scores/<NUM>/score.ini
score.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
<?phpopening tags, PascalCase classes, camelCase methods.- 3-space indentation, opening brace on the same line (legacy style).
- Consistent with surrounding code; prefer
include_once/require_oncefor includes. - No database layer: file operations via
ScoreScanner.php.
JavaScript / Svelte
- Svelte 5 runes (
$state,$props,$derived,$effect); prefer$app/stateover the deprecated$app/stores. - TypeScript, camelCase variables, PascalCase components, 2-space indent, semicolons.
General
- UTF-8, French for user-facing content.
- Do not add comments unless necessary.
- NEVER delete files or directories without asking first.
Design Guidelines
- Clean, sober, modern.
- Use SVG icons instead of emojis.
- Integrate related actions (view + download) in a single row with consistent styling.
- Hide redundant labels ("Piece 1", "Version 1") when there is only one.
- Use spacing, subtle borders, and hover effects for visual hierarchy.
Security Checklist (before deployment)
JWT_SECRETis strong and unique (openssl rand -base64 32); never commitapi/.env.api/config/users.jsonuses non-default credentials (changeadmin/user).- CORS origins restricted to the real domains (no wildcard fallback).
- HTTPS enforced in production.
- Rate limiting active (clear
/tmp/rate_*only in tests, never in prod). php tests.phppasses 185/185.npm auditreviewed.- No secrets in code or git history.
Manual checks:
# JWT login
curl -s http://localhost:8000/login -X POST \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"password"}'
# Directory traversal must NOT return 200
curl -s http://localhost:8000/download/../../../etc/passwd \
-H "Authorization: Bearer <token>"
# 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.phpno longer serves files statically (.env/config/users.jsonare not reachable);deleteScoreFileenforcesrealpath()containment and only deletesapi/.envandpartitions/.envare untracked/gitignored (rotate the secret since it remains in git history); JWT uses base64url, requiresexpand validatesalg; credentials errors are generic; theressourcelink rejects non-http(s) URLs.Known findings still open: default
admin/passwordcredentials; login rate limiter is race-prone (noflock) and/api/loginbypasses it; CORS falls back to*for unknown origins;GET /admin/scores/:idpasses extra arguments dropped byScoreScanner::updateScore(soressourceis not persisted); watermarking only keeps page 1 of multi-page PDFs;createScoreanduploadPdfdo not validate path segments;updateScore/updatePiecesdo not sanitize INI values.
Temporary Files
- Put temporary scripts and outputs in
_builds/(gitignored). scripts/convert_final_v2.jsis the only committed script inscripts/.
Git
- Default branch:
master. - No CI/CD configured.
- Only commit when explicitly requested.