Files
ohmj2/AGENTS.md
T

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 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)

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)

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

./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_proxys 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.

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.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)

./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.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.

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 /login with {"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 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. 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

  • <?php opening tags, PascalCase classes, camelCase methods.
  • 3-space indentation, opening brace on the same line (legacy style).
  • Consistent with surrounding code; prefer include_once/require_once for includes.
  • No database layer: file operations via ScoreScanner.php.

JavaScript / Svelte

  • Svelte 5 runes ($state, $props, $derived, $effect); prefer $app/state over 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_SECRET is strong and unique (openssl rand -base64 32); never commit api/.env.
  • api/config/users.json uses non-default credentials (change admin/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.php passes 185/185.
  • npm audit reviewed.
  • 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.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.