Skip to content

feat(interop): v0.7.5 — French locale (fr_FR), BNF SRU/UNIMARC, all interop plugins (OAI-PMH, NCIP, Z39.50, VIAF, BIBFRAME, ResourceSync)#132

Open
fabiodalez-dev wants to merge 101 commits into
mainfrom
feat/fr-bnf-integration
Open

feat(interop): v0.7.5 — French locale (fr_FR), BNF SRU/UNIMARC, all interop plugins (OAI-PMH, NCIP, Z39.50, VIAF, BIBFRAME, ResourceSync)#132
fabiodalez-dev wants to merge 101 commits into
mainfrom
feat/fr-bnf-integration

Conversation

@fabiodalez-dev
Copy link
Copy Markdown
Owner

Summary

  • French locale (fr_FR): full translation (4145 keys, 100%), BNF SRU scraping with UNIMARC mapping, locale routes for French
  • Migration fixes: migrate_0.7.5.sql uses ON DUPLICATE KEY UPDATE to activate fr_FR on upgrades (was INSERT IGNORE); Language::setDefault() now auto-activates the language to prevent is_default=1, is_active=0 inconsistency
  • Dev-schema guard in migrate_0.7.0.sql: drops and recreates author_authority_alternates if old pre-release dev columns (source_code) are detected — fixes upgrade failure on installations that had dev migrations applied manually
  • de_DE routes: added missing bibframe.book key to routes_de_DE.json (parity with it_IT, en_US, fr_FR)
  • Interop plugins: OAI-PMH (v0.7.4 conformance), NCIP server (admin UI, schema, seeds), Z39.50 server (ensureSchema() fix), BIBFRAME/RDF, ResourceSync, VIAF authority control
  • Archives plugin: multi-document uploads, UI polish

Root causes fixed in this batch

fr_FR not activating after upgrade

migrate_0.7.5.sql used INSERT IGNORE — if the row existed with is_active=0, the row was silently skipped. Switched to ON DUPLICATE KEY UPDATE is_active=1.

Site staying Italian after setting French as default

I18n::loadFromDatabase() queries WHERE is_active=1 — an is_active=0 language is invisible to the entire locale resolution chain even if is_default=1. Root fix: Language::setDefault() now forces is_active=1 on the target language.

"Key column 'source' doesn't exist in table" on upgrade

Some installations had author_authority_alternates created by an early dev migration with column source_code instead of source. CREATE TABLE IF NOT EXISTS skipped the table, then the subsequent ADD KEY idx_authority (source, authority_id) failed. Fixed by adding a guard that DROPs the table when the old source_code column is detected.

Test Plan

  • Fresh install: run installer wizard, verify all languages available
  • Upgrade from v0.7.4: apply ZIP via admin updater, verify fr_FR activated and site loads
  • Set fr_FR as default, verify site switches to French
  • Upgrade from installation with dev-schema author_authority_alternates: verify migration completes without error
  • PHPStan level 5: phpstan analyse --memory-limit=512M (0 errors)
  • E2E suite: full-test.spec.js (97 tests)

🤖 Generated with Claude Code

…bels (#123, #121)

- Add iiif_manifest_url VARCHAR(2000) column to archival_units (idempotent migration)
- GET /admin/archives/{id}/manifest.json and /archives/{id}/manifest.json return
  IIIF Presentation API 3.0 JSON with metadata crosswalk, seeAlso links to DC/OAI-PMH,
  and a Canvas for locally stored cover images; CORS header for external viewers
- iiif_manifest_url field in edit form links to external IIIF server manifests
- <link rel="alternate"> for manifest.json added to admin show and public detail pages
- form.php: wrap fields in ISAD(G) area section headers (Area 1 Identity Statement,
  Area 3 Content & Structure, Area 4 Conditions of Access) matching AtoM presentation
…tion endpoint

- iiifManifestAction: real canvas width/height via getimagesize() (fallback 1500×2000)
- iiifManifestAction: partOf link to parent Collection or root collection.json
- iiifManifestAction: structures[] with ancestor breadcrumb Range (IIIF TOC)
- iiifBuildAncestorChain(): walk parent_id chain to build Fondo › Serie › … path
- iiifCollectionAction(): GET /archives/collection.json (all top-level fondi)
  and GET /archives/{id}/collection.json (direct children as Manifest or Collection)
  — child units with children become sub-Collections, leaf units stay Manifests
- Added two public routes for Collection endpoints (CORS header included)
…authority links

- behavior: viewer UX hint (paged/individuals/unordered) derived from specific_material
- requiredStatement + provider: institution attribution as IIIF Agent (machine + human)
- rights: new rights_statement_url column (VARCHAR 500) exposed as manifest['rights']
- structures: replace flat breadcrumb with nested Range hierarchy (§1.1) — each
  ancestor wraps the next as an outer Range; innermost Range holds Canvas items
- authority links: creator/subject names + VIAF/Wikidata URIs from external_refs
  added to manifest metadata
- form: rights_statement_url field in ISAD(G) Area 4 (Conditions of Access)
- new private helpers: iiifBuildNestedStructures(), iiifFetchAuthoritiesWithRefs()
…oints

- ARK identifier (ark_identifier VARCHAR 255): surfaced in IIIF seeAlso via
  n2t.net resolver, EAD3 <recordid>, Dublin Core dc:identifier, form Area 1
- Version note (version_note VARCHAR 500): shown in IIIF metadata, form
- EAD3 per-unit: GET /archives/{id}/ead.xml (public + admin) — single-unit
  finding aid from existing writeEad3Document; adds <dao> IIIF manifest link
  and optional cover image to the bulk exporter as well
- METS per-unit: GET /archives/{id}/mets.xml (public + admin) — wraps DC
  inline + EAD3 by reference + IIIF manifest link; suitable for Europeana
  submission; covers fileSec with thumbnail if cover image present
- Dublin Core: adds ark as second dc:identifier, rights_statement_url as dc:rights
- IIIF seeAlso: adds EAD3, METS, ARK entries alongside existing DC and OAI-PMH
- headLinks: EAD3 and METS link rel="alternate" in admin and public show views
…erop standards

Covers all new fields (ark_identifier, version_note, rights_statement_url),
IIIF 3.0 advanced features (behavior, nested Ranges, requiredStatement, provider,
rights, authority links), per-unit EAD3 and METS exports, headLinks, and
ISAD(G) form labels validation.
…new fields in show.php

version_note and iiif_manifest_url are record-level metadata (not material-specific)
— moved outside the collapsible <details> accordion so they are always visible.
Added ark_identifier, rights_statement_url, version_note display to admin show view.
Fixed E2E test 8 to open the accordion before selecting specific_material.
All 25 interop E2E tests now pass.
… seed for new fields

- tests/seeds/librarything-aldebaran.tsv: 18 Aldebaran volumes (4 cycles)
  to test LibraryThing series import with collana/numero_serie parsing
- tests/seeds/books-general.csv: 15 classic books for standard CSV import
- tests/seeds/books-seed.sql: idempotent SQL seed (INSERT IGNORE) for fast
  setup; covers all 33 books, authors, publishers, series, and libri_collane links
- tests/seeds/archives-feature-20.sql (v2): adds ark_identifier,
  rights_statement_url, version_note to temp table and INSERT/ON DUPLICATE KEY
- .gitignore: allow *.csv and *.tsv in tests/seeds/
P1 — fresh-install schema integrity:
- Add 4 missing columns to ddlArchivalUnits() DDL (iiif_manifest_url,
  rights_statement_url, ark_identifier, version_note) so a fresh CREATE TABLE
  includes all interoperability-standards columns without needing ALTER
- Move migrateImageColumns() call to AFTER the CREATE TABLE foreach loop so
  the table exists before the additive ALTER migrations attempt to run

P2 — language-code parsing:
- iiifManifestAction(): use preg_split('/[,;\s]+/', ...) instead of
  explode(',', ...) so seed values like 'ita;eng' are correctly split
- Dublin Core output: replace single compound writeElementNs with a loop that
  emits one dc:language element per code (RFC 4646 / MARC 21 compliant)
…-up)

The seed archives-feature-20.sql inserts iiif_manifest_url,
rights_statement_url, ark_identifier, and version_note, but the
migration that archives-feature-documents.spec.js applies on a clean
DB stopped at document_filename. On a fresh install with the migration-
only path (no ensureSchema() call), the seed would fail with
'Unknown column'. Add four idempotent ALTER TABLE blocks following the
same pattern as all other additive columns in the migration.
cleanupTag (spec) — FK-safe deletion order: delete archival_unit_authority
rows first via JOIN, then archival_units children (parent_id IS NOT NULL)
then roots; avoids MySQL 1093 from self-referential NOT EXISTS subquery.

show.php — validate rights_statement_url scheme before rendering as <a href>:
only http/https URLs get a clickable link; anything else (incl. javascript:)
renders as plain escaped text, preventing href injection.

ArchivesPlugin — IIIF 3.0 §3.3 compliance: manifest items must have
cardinality ≥ 1; when no local cover image is present, add a minimal
placeholder Canvas so clients receive a valid (if unpainted) manifest.

ArchivesPlugin — EAD3 schema fix: move <daoset> block inside <did> (before
its closing tag) where EAD3 requires it; the block was previously emitted
after </did>, violating the EAD3 DTD.

form.php — add rel="noopener noreferrer" to external rightsstatements.org
link that uses target="_blank" (prevents tabnabbing/opener leakage).
ScrapeController::byIsbn() now calls session_write_close() before
hitting Discogs/OpenLibrary. PHP file-based sessions hold an exclusive
lock for the entire request duration, causing subsequent page.goto()
calls in Playwright (or any same-session request) to block until the
slow external fetch completes — typically 10–30 s, long enough to
cascade-fail all subsequent serial tests.

Also hardens seed-catalog.spec.js: replaces waitForTimeout(8000) with
waitForLoadState('networkidle'), adds trySave() helper that logs a
warning instead of throwing on save failure (seeder resilience), and
clears isbn13/isbn10 before fallback fill to avoid unique-constraint
violations.
ci-quality.yml (new):
- PHP syntax check with xargs -P4 on app/, config/, public/, installer/, server/
- PHPStan level 5 (mirrors pre-commit hook in CI for PRs from forks or CI-only runs)
- Composer security audit against Packagist CVE database
- Triggers on any *.php / composer.json / phpstan.neon change

codeql.yml (new):
- CodeQL security-and-quality queries for PHP and JavaScript
- Detects SQL injection, XSS, path traversal, CSRF, open redirect
- Runs on push/PR to main + weekly schedule

test-migrations.yml (updated):
- Added trigger on version.json changes
- Job verify-versions: checks every migrate_*.sql has version ≤ version.json;
  catches the silent-skip bug (updater guards with version_compare <=)
- Job test-full-chain: applies schema.sql + all migrations in sort -V order,
  then asserts 5 expected tables and 6 expected columns from 0.5.x/0.5.9.x
  migrations are present
- Existing job test-migrations (0.4.0 upgrade path) preserved unchanged
…ckout@v6

PHPStan installed via setup-php tools: doesn't auto-discover phpstan.neon
when run as a global binary in some 2.x versions, causing "At least one
path must be specified to analyse". Passing --configuration makes it
explicit and version-agnostic.

Also upgrades actions/checkout v4→v6 and actions/cache v4→v5 across all
three workflows to suppress the Node.js 20 deprecation warning (Node 24
becomes default June 2026).
Adds E2E_DB_HOST and E2E_DB_PORT env vars to mysqlArgs() in
archives-seed-50, archives-interop-standards and full-test specs.
Prioritises TCP (host+port) over socket when DB_HOST is set, enabling
remote DB access via SSH wrapper for production smoke-tests.
Both files were gitignored, so GitHub Actions could not find the
PHPStan config file during CI runs (exit 1: "does not exist").
Remove them from .gitignore so the runner picks them up on checkout.
v3 (toolset 2.25.3) does not recognize 'php' as a language, causing
the Analyze (php) job to fail. v4 restores PHP support and removes
the Node.js 20 deprecation warnings.
The /server/ directory is gitignored and absent in CI, causing
PHPStan to abort with "Path does not exist". No analyzed code
in app/ or plugins references classes from server/src, so the
entry is safe to remove.
PHP 8.2 constants like CURLOPT_REDIR_PROTOCOLS_STR were reported as
'not found' because phpVersion was set to 80100. The CI and production
server both run PHP 8.2; aligning the phpstan target version prevents
false positives on 8.2-only constants.
With phpVersion bumped to 80200, PHPStan resolves CURLOPT_REDIR_PROTOCOLS_STR
and collapses the old array-shape offset errors. Regenerated baseline now
captures only the two remaining legitimate suppressions.
PHPStan 2.1.54 (installed in CI via tools:phpstan) reports:
- CURLOPT_REDIR_PROTOCOLS_STR not found: constant is PHP 8.2 but absent
  from PHPStan 2.1.54 stubs; guarded by defined() at runtime
- isset.offset for traduttore/illustratore: stricter array-shape
  narrowing in 2.1.54 vs 2.1.40 (reportUnmatchedIgnoredErrors=false
  keeps these baseline entries harmless on older local versions)
thepixeldeveloper/sitemap is abandoned (no replacement available) —
the audit exits 2 by default. --abandoned=report logs it without
failing the pipeline.
CodeQL CLI 2.25.x does not recognize 'php' as a language, causing the
Analyze (php) job to fail. PHP security is already covered by PHPStan
level 5. Retain only javascript-typescript CodeQL analysis.
- tests/archives-interop-standards.spec.js: add escapeLike() helper and use
  ESCAPE '\\' in cleanupTag() LIKE queries to prevent SQL wildcard expansion
  on underscores in TAG (critical fix); remove -p${DB_PASS} from argv and
  pass password via MYSQL_PWD env var instead; fix test 12 n2t.net check
  to use startsWith() instead of includes(); refactor test 25 to use
  Playwright locators instead of page.content(); add Number.isInteger()
  guard in afterAll cleanup
- tests/archives-seed-50.spec.js: same MYSQL_PWD argv fix
- tests/full-test.spec.js: same MYSQL_PWD argv fix
- .github/workflows/test-migrations.yml: add workflow file to PR paths;
  replace || true with targeted idempotency handler that propagates
  unexpected errors; add rights_statement_url to column count (6→7)
- installer/database/migrations/migrate_0.5.9.7.sql: add UNIQUE KEY on
  archival_units.ark_identifier for upgrade paths
- storage/plugins/archives/ArchivesPlugin.php: add URL/length validation
  for iiif_manifest_url, rights_statement_url, ark_identifier, version_note
  in validateArchivalUnit(); add app-level ARK uniqueness check using
  excludeId to allow in-place updates; add UNIQUE KEY uq_ark_identifier to
  ensureSchema() CREATE TABLE; fix O(n) per-child queries in
  iiifCollectionAction() by computing has_children via EXISTS() subquery in
  the initial SELECT
- storage/plugins/archives/views/form.php: wrap placeholder in $e(); wrap
  parent_id help text in __()/$e() for i18n consistency
- app/Controllers/ScrapeController.php: add comment after session_write_close()
  warning plugin authors not to write to $_SESSION in Hook callbacks
- app/Support/MaintenanceService.php: add expired_pickups? to runIfNeeded()
  return type shape to fix PHPStan nullCoalesce.offset warning properly
- phpstan-baseline.neon: remove expired_pickups suppression (now fixed in
  the type annotation)
- tests/seeds/archives-feature-20.sql: version_note TEXT → VARCHAR(500) to
  match archival_units column type and prevent silent truncation
- migrate_0.5.9.7.sql: add deduplication pre-check before UNIQUE KEY
  (NULL-out duplicate ARKs keeping earliest row, guarded by idx_exists)
- phpstan-baseline.neon: remove 3 stale suppressions (traduttore,
  illustratore, CURLOPT_REDIR_PROTOCOLS_STR); keep booleanNot.alwaysTrue
  which is a real PHPStan finding in QueryCache while-loop
- ArchivesPlugin validateArchivalUnit(): remove AND deleted_at IS NULL
  from ARK uniqueness check to align with DB UNIQUE KEY semantics
- ArchivesPlugin iiifCollectionAction(): restrict root collection to
  level='fonds' only (parent_id IS NULL was too broad)
- tests: (body['seeAlso'] || []).find(...) guard against absent seeAlso;
  'Content & Structure' selector instead of ambiguous 'Content'
The migration renames the typo column classificazione_dowey→classificazione_dewey.
On fresh schema installs the correct column already exists, so CHANGE COLUMN
fails with 'Unknown column' — not in the CI tolerated-error list. Wrap with
INFORMATION_SCHEMA guard (same pattern as migrate_0.5.9.7.sql).
…0.5.9.7 upgrades

migrate_0.5.9.sql has version 0.5.9 which is older than the released 0.5.9.6,
so it never runs when upgrading from 0.5.9.x. This caused Test B of the reinstall
test to fail with 'archival_units doesn't exist'. The fix merges the full archives
DDL (CREATE TABLE IF NOT EXISTS + idempotent column additions + plugin row) into
migrate_0.5.9.7.sql so that any upgrade path from before 0.5.9.7 creates all
archives tables in a single idempotent pass.

Also move the book-type badge above the <h1> on the frontend book-detail page and
set an absolute font-size (0.7rem) so it renders small regardless of heading size.
…0.5.9.7 upgrades

migrate_0.5.9.sql has version 0.5.9 which is older than released 0.5.9.6,
so it never runs when upgrading from 0.5.9.x. Confirmed by reinstall-test.sh
Test B: upgrade from v0.5.9.6 failed with 'archival_units doesn't exist'.

Merged the full archives DDL into this file: CREATE TABLE IF NOT EXISTS for all
4 tables, idempotent column additions via INFORMATION_SCHEMA guards, plugin row
upsert, and the original dedup+UNIQUE KEY — so any pre-0.5.9.7 upgrade path
gets all archives tables in a single idempotent pass.
PHPStan baseline:
- Restore 3 suppressed entries removed erroneously: CURLOPT_REDIR_PROTOCOLS_STR
  (PHP 8.1 compatibility), traduttore/illustratore isset.offset in BookRepository

migrate_0.5.9.7.sql:
- Fix plugin version 1.0.0 → 1.1.0 to match storage/plugins/archives/plugin.json
- Use full extended ENUM for specific_material ADD COLUMN (avoids redundant MODIFY)
- Emit dedup count as migration_notice before nulling duplicate ARKs

ArchivesPlugin.php:
- dc.xml headLinks/seeAlso: application/rdf+xml → application/xml (OAI-DC is not
  RDF/XML; wrong MIME breaks parsers)
- validateArchivalUnit: normalize resolver URLs (https://n2t.net/ark:/...) to
  canonical ark:/... form and reject non-ARK strings with a clear error message
- migrateImageColumns: add idempotent guard for uq_ark_identifier UNIQUE KEY so
  in-place upgrades get the same DB-level uniqueness as fresh installs

tests/archives-interop-standards.spec.js:
- Test 20 EAD3 bulk export: remove manual Cookie forwarding (page.request already
  shares the browser context's storage)
… dev schema detected

Pre-release dev builds used source_code/source_id column names. The current
migration expects source/authority_id. CREATE TABLE IF NOT EXISTS skipped the
DDL on installations that had the dev table, causing ADD KEY idx_authority
(source, authority_id) to fail with 'Key column source does not exist'.

Add a guard that detects the old schema via source_code column presence and
drops the empty dev table before recreating with the correct schema.
const { execFileSync } = require('child_process');

const DB_HOST = process.env.E2E_DB_HOST || '';
const DB_PORT = process.env.E2E_DB_PORT || '';
return args;
}

function dbQuery(sql) {
const fs = require('fs');
const path = require('path');

const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081';
Comment thread tests/install-compat-standard.spec.js Fixed
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 11, 2026

Important

Review skipped

Too many files!

This PR contains 163 files, which is 13 over the limit of 150.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fdf73911-765d-4dc4-bbec-7cc1160ee5d6

📥 Commits

Reviewing files that changed from the base of the PR and between d630ced and 9d70038.

⛔ Files ignored due to path filters (7)
  • tests/fixtures/archive-test-audio.mp3 is excluded by !**/*.mp3
  • tests/fixtures/archive-test-cover.jpg is excluded by !**/*.jpg
  • tests/fixtures/archive-test.pdf is excluded by !**/*.pdf
  • tests/fixtures/test_import_languages.csv is excluded by !**/*.csv
  • tests/fixtures/test_import_languages.tsv is excluded by !**/*.tsv
  • tests/seeds/books-general.csv is excluded by !**/*.csv
  • tests/seeds/librarything-aldebaran.tsv is excluded by !**/*.tsv
📒 Files selected for processing (163)
  • .gitattributes
  • .github/workflows/ci-e2e.yml
  • .github/workflows/ci-quality.yml
  • .github/workflows/ci-upgrade-smoke.yml
  • .github/workflows/codeql.yml
  • .github/workflows/test-migrations.yml
  • .gitignore
  • README.md
  • app/Controllers/FrontendController.php
  • app/Controllers/ScrapeController.php
  • app/Controllers/SearchController.php
  • app/Models/Language.php
  • app/Support/BundledPlugins.php
  • app/Support/HtmlHelper.php
  • app/Support/MaintenanceService.php
  • app/Support/PluginManager.php
  • app/Support/QueryCache.php
  • app/Support/RouteTranslator.php
  • app/Support/Updater.php
  • app/Views/admin/plugins.php
  • app/Views/frontend/book-detail.php
  • app/Views/frontend/catalog.php
  • app/Views/frontend/layout.php
  • app/Views/layout.php
  • app/Views/libri/partials/book_form.php
  • app/Views/user_layout.php
  • bin/pinakes
  • config/default_texts.php
  • installer/classes/Installer.php
  • installer/database/data_de_DE.sql
  • installer/database/data_en_US.sql
  • installer/database/data_fr_FR.sql
  • installer/database/data_it_IT.sql
  • installer/database/migrations/migrate_0.3.0.sql
  • installer/database/migrations/migrate_0.4.3.sql
  • installer/database/migrations/migrate_0.5.9.7.sql
  • installer/database/migrations/migrate_0.5.9.sql
  • installer/database/migrations/migrate_0.6.0.sql
  • installer/database/migrations/migrate_0.7.0.sql
  • installer/database/migrations/migrate_0.7.4.sql
  • installer/database/migrations/migrate_0.7.5.sql
  • installer/database/schema.sql
  • installer/index.php
  • installer/steps/step0.php
  • locale/de_DE.json
  • locale/en_US.json
  • locale/fr_FR.json
  • locale/it_IT.json
  • locale/routes_de_DE.json
  • locale/routes_en_US.json
  • locale/routes_fr_FR.json
  • locale/routes_it_IT.json
  • phpstan-baseline.neon
  • phpstan.neon
  • scripts/create-release.sh
  • storage/plugins/api-book-scraper/ApiBookScraperPlugin.php
  • storage/plugins/api-book-scraper/views/settings.php
  • storage/plugins/archives/ArchivesPlugin.php
  • storage/plugins/archives/assets/css/archives-public.css
  • storage/plugins/archives/plugin.json
  • storage/plugins/archives/views/authorities/show.php
  • storage/plugins/archives/views/form.php
  • storage/plugins/archives/views/index.php
  • storage/plugins/archives/views/public/index.php
  • storage/plugins/archives/views/public/show.php
  • storage/plugins/archives/views/show.php
  • storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php
  • storage/plugins/bibframe-linked-data/plugin.json
  • storage/plugins/bibframe-linked-data/wrapper.php
  • storage/plugins/dewey-editor/DeweyEditorPlugin.php
  • storage/plugins/dewey-editor/classes/DeweyValidator.php
  • storage/plugins/dewey-editor/views/editor.php
  • storage/plugins/ncip-server/NcipServerPlugin.php
  • storage/plugins/ncip-server/plugin.json
  • storage/plugins/ncip-server/views/partners.php
  • storage/plugins/ncip-server/views/transactions.php
  • storage/plugins/ncip-server/wrapper.php
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php
  • storage/plugins/oai-pmh-server/plugin.json
  • storage/plugins/oai-pmh-server/views/book-digital-assets.php
  • storage/plugins/oai-pmh-server/wrapper.php
  • storage/plugins/open-library/OpenLibraryPlugin.php
  • storage/plugins/openurl-resolver/OpenUrlResolverPlugin.php
  • storage/plugins/openurl-resolver/plugin.json
  • storage/plugins/openurl-resolver/wrapper.php
  • storage/plugins/resource-sync/ResourceSyncPlugin.php
  • storage/plugins/resource-sync/plugin.json
  • storage/plugins/resource-sync/wrapper.php
  • storage/plugins/viaf-authority/ViafAuthorityPlugin.php
  • storage/plugins/viaf-authority/plugin.json
  • storage/plugins/viaf-authority/wrapper.php
  • storage/plugins/z39-server/Z39ServerPlugin.php
  • storage/plugins/z39-server/classes/DublinCoreFormatter.php
  • storage/plugins/z39-server/classes/MARCXMLFormatter.php
  • storage/plugins/z39-server/classes/MODSFormatter.php
  • storage/plugins/z39-server/classes/RecordFormatter.php
  • storage/plugins/z39-server/classes/SRUServer.php
  • storage/plugins/z39-server/classes/SruClient.php
  • storage/plugins/z39-server/classes/UNIMARCXMLFormatter.php
  • storage/plugins/z39-server/endpoint.php
  • tests/archives-00-schema.spec.js
  • tests/archives-50-elements-crud.spec.js
  • tests/archives-authorities.spec.js
  • tests/archives-bug-fixes.spec.js
  • tests/archives-crud.spec.js
  • tests/archives-full.spec.js
  • tests/archives-interop-standards.spec.js
  • tests/archives-multi-documents.spec.js
  • tests/archives-plugin.unit.php
  • tests/archives-pr-extended.spec.js
  • tests/archives-search.spec.js
  • tests/archives-seed-50.spec.js
  • tests/archives-unit-files.unit.php
  • tests/archives-upload-assets.spec.js
  • tests/bibframe-linked-data.spec.js
  • tests/bibframe-persistent-uri.spec.js
  • tests/bnf-sru-features.spec.js
  • tests/book-form-comprehensive.spec.js
  • tests/branch-fix-consistency.spec.js
  • tests/bulk-enrich.spec.js
  • tests/code-quality.spec.js
  • tests/discogs-advanced.spec.js
  • tests/discogs-import.spec.js
  • tests/email-notifications.spec.js
  • tests/fair-signposting.spec.js
  • tests/fr-bnf-merge-consistency.spec.js
  • tests/full-test.spec.js
  • tests/helpers/branch-fix-harness.php
  • tests/install-compat-standard.spec.js
  • tests/install-english-issue112.spec.js
  • tests/install-french.spec.js
  • tests/interop-00-activate-plugins.spec.js
  • tests/interop-document-coverage.spec.js
  • tests/interop-specific.spec.js
  • tests/interop-standards.spec.js
  • tests/loan-reservation.spec.js
  • tests/mag-validation.spec.js
  • tests/multilang-install-i18n.spec.js
  • tests/multisource-scraping.spec.js
  • tests/ncip-admin-ui.spec.js
  • tests/ncip-server.spec.js
  • tests/oai-pmh-server.spec.js
  • tests/openurl-resolver.spec.js
  • tests/plugin-integrity.spec.js
  • tests/plugin-lifecycle.spec.js
  • tests/pr132-fix-regressions.spec.js
  • tests/resource-sync.spec.js
  • tests/schema-integrity.spec.js
  • tests/seed-catalog.spec.js
  • tests/seeds/archives-feature-20.sql
  • tests/seeds/archives-unit-files.sql
  • tests/seeds/authors-with-viaf.sql
  • tests/seeds/books-seed.sql
  • tests/seeds/ncip-partners.sql
  • tests/seeds/oai-conformance-set.sql
  • tests/series-collane-crud.spec.js
  • tests/series-cycles.spec.js
  • tests/smoke-install.spec.js
  • tests/sru-unimarcxml.spec.js
  • tests/unimarc-export.spec.js
  • tests/viaf-authority.spec.js
  • tests/viaf-reconcile.spec.js
  • version.json

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/fr-bnf-integration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

…sue #130)

On Windows, RecursiveDirectoryIterator::getPathname() returns backslash paths,
breaking str_replace prefix-stripping and mkdir target construction in
copyDirectoryRecursive() and copyDirectory(). All paths now normalized to
forward slashes at function entry; realpath() returns also normalized.

Also remove unused SEED_FILES variable from install-compat-standard.spec.js
(CodeQL js/unused-local-variable #20) and harden test selectors.
Comment thread tests/archives-upload-assets.spec.js Fixed
Comment thread tests/archives-upload-assets.spec.js Fixed
Comment thread tests/archives-upload-assets.spec.js Fixed
Comment thread tests/archives-upload-assets.spec.js Fixed
Comment thread tests/archives-upload-assets.spec.js Fixed
Comment thread tests/archives-upload-assets.spec.js Fixed
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 11, 2026

✅ Actions performed

Full review triggered.

…ition

archives-upload-assets.spec.js ensureUploadFixtures():
- Remove existsSync+writeFileSync TOCTOU pattern (race condition fix)
- Replace hardcoded /tmp/ paths with path.join(os.tmpdir(), ...) for
  cross-platform safety and to satisfy CodeQL js/insecure-tmp rule
Comment thread tests/archives-upload-assets.spec.js Fixed
Comment thread tests/archives-upload-assets.spec.js Fixed
Comment thread tests/archives-upload-assets.spec.js Fixed
__() uses Italian as the native fallback — keys ARE Italian text, so
if a key is missing from it_IT.json the function returns the key itself.
it_IT.json is intentionally sparse (overrides only).

Only en_US ↔ de_DE requires exact key-set parity.
CodeQL js/insecure-temporary-file:
  - Add binary fixtures to tests/fixtures/ (jpg/pdf/mp3, csv/tsv) and
    allow the directory in .gitignore (mirrors tests/seeds/ pattern)
  - archives-upload-assets.spec.js reads from tests/fixtures/ instead of
    writing predictable filenames to os.tmpdir()

OAI-PMH:
  - Catch remaining \Throwable in record serialisation loop to skip
    malformed metadata with a warning log instead of crashing

E2E test stability:
  - bibframe-persistent-uri: check @id contains /id/work/{id}, not suffix /work
  - book-form-comprehensive: simplified Choices.js check (no XPath)
  - bulk-enrich: nullify ISBN before DELETE to avoid UK constraint on re-run
  - multisource-scraping: accept 'Z39.50/SRU' as valid source for Nevermind
  - smoke-install: make author input fill conditional (visible guard)
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

fabiodalez-dev commented May 11, 2026

Code review

Branch: feat/fr-bnf-integrationmain
PR: #132 (open)
Review ID: rev_01KRB6B3SXD2MQW9F49YFWA7Z3
Sub-agent tokens: 2,232,501 across 31 invocations

Found 29 findings across all lanes:

  • Deep lane (correctness/security): 3 resolved, 2 uncertain

Deep lane — correctness & security

✓ Auto-fixable (3)

# Score Impact File Issue Status
F002 78 correctness storage/plugins/archives/ArchivesPlugin.php:1714-1716 In removeFileAction(), $stmt->get_result() is called and the return value is used directly as a mysqli_result without an instanceof \mysqli_result guard. If get_result() returns false (non-mysqlnd driver, or any query error), calling ->fetch_assoc() on false causes a fatal PHP TypeError. Every other get_result() call in the codebase uses the guard pattern. ✓ fixed and verified (6a93828)
F003 78 correctness storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php:560-568 Scalar predicate value in toTurtle() uses addslashes() instead of the proper escapeTurtleString() helper used elsewhere in the same file, so scalar properties containing newline/tab/carriage-return characters produce malformed Turtle that parsers will reject. ✓ fixed and verified (6a93828)
F007 60 correctness storage/plugins/archives/ArchivesPlugin.php:6421-6425 archiveSearchPattern() builds a SQL LIKE pattern by concatenating '%' . $q . '%' without ESCAPE-clause-safe escaping of the literal % and _ wildcards in $q, so users searching for an underscore or percent get wrong matches. ✓ fixed and verified (6a93828)
Details and fix proposals

F002 — In removeFileAction(), $stmt->get_result() is called and the return value is used directly as a mysqli_result without an instanceof \mysqli_result guard. If get_result() returns false (non-mysqlnd driver, or any query error), calling ->fetch_assoc() on false causes a fatal PHP TypeError. Every other get_result() call in the codebase uses the guard pattern.

File: storage/plugins/archives/ArchivesPlugin.php:1714-1716
Score: 78 (strong)

Evidence:

  • storage/plugins/archives/ArchivesPlugin.php:1714 — $result = $stmt->get_result() assigns without instanceof guard
  • storage/plugins/archives/ArchivesPlugin.php:1715 — $file = $result->fetch_assoc() directly dereferences; on non-mysqlnd builds get_result() returns false, causing fatal Error
  • storage/plugins/archives/ArchivesPlugin.php:4680 — $row = $stmt->get_result()->fetch_assoc() chained form with same defect in iiifManifestAction
  • 47 instanceof \mysqli_result guards elsewhere in the same file establish this as the standard defensive pattern
  • PHP 8 raises Error (not TypeError) on method call on bool — unhandled, surfaces as HTTP 500

Approach: Add instanceof \mysqli_result guard at two unguarded sites: lines 1714-1715 in removeFileAction and line 4680 in iiifManifestAction, mirroring the 47 existing guarded sites

Files to modify:

  • storage/plugins/archives/ArchivesPlugin.php — Replace line 1714-1715 assignment with: $result = $stmt->get_result(); $file = $result instanceof \mysqli_result ? $result->fetch_assoc() : null; (why: Existing null branch at 1718 already handles failure case correctly with 303 redirect)
  • storage/plugins/archives/ArchivesPlugin.php — Refactor line 4680 chained call into two-statement form with guard: $res = $stmt->get_result(); $row = $res instanceof \mysqli_result ? $res->fetch_assoc() : null; (why: Same root cause; existing null check at 4683 returns 404 JSON)

Verification:

  • grep -n 'get_result()' storage/plugins/archives/ArchivesPlugin.php — confirm every call followed within 3 lines by instanceof guard
  • phpstan analyse --memory-limit=512M storage/plugins/archives/ArchivesPlugin.php

Edge cases to preserve:

  • Existing 303 redirect when fileId not found must still trigger via null branch
  • Filesystem unlink must only run when DB delete succeeded

Latest fix attempt (fixrun_01KRBB2ZT91936NP8ZB0X076FB): fixed and verified

F003 — Scalar predicate value in toTurtle() uses addslashes() instead of the proper escapeTurtleString() helper used elsewhere in the same file, so scalar properties containing newline/tab/carriage-return characters produce malformed Turtle that parsers will reject.

File: storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php:560-568
Score: 78 (strong)

Evidence:

  • storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php:562 — uses addslashes((string)$val) for scalar literal Turtle output
  • storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php:617,645 — both other literal-emission sites correctly use $this->escapeTurtleString()
  • escapeTurtleString() (lines 655-662) escapes backslash, quote, newline, carriage-return, tab; addslashes() misses newline/carriage-return/tab
  • Commit c84f03c fixed blank-node branch but missed top-level scalar branch at line 562
  • A scalar value containing literal LF (e.g. multi-line note) produces malformed Turtle STRING_LITERAL_QUOTE parsers will reject

Approach: Single-line fix: replace addslashes() with $this->escapeTurtleString() at line 562 so all three literal-emission paths share one escaping function

Files to modify:

  • storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php — At line 562 replace addslashes((string) $val) with $this->escapeTurtleString((string) $val) (why: addslashes() leaves LF/CR/TAB intact, violating Turtle STRING_LITERAL_QUOTE grammar; escapeTurtleString() already implements the full escape set used by sibling paths at lines 617 and 645)

Verification:

  • grep -n 'addslashes' storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php — must return 0 after fix
  • grep -n 'escapeTurtleString' storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php — all three sites (562, 617, 645) must use it

Edge cases to preserve:

  • Empty string scalar must still emit empty quoted string
  • ASCII-only scalars unchanged
  • Single quote — addslashes escapes unnecessarily; escapeTurtleString leaves raw (correct for Turtle)

Latest fix attempt (fixrun_01KRBB2ZT91936NP8ZB0X076FB): fixed and verified

F007 — archiveSearchPattern() builds a SQL LIKE pattern by concatenating '%' . $q . '%' without ESCAPE-clause-safe escaping of the literal % and _ wildcards in $q, so users searching for an underscore or percent get wrong matches.

File: storage/plugins/archives/ArchivesPlugin.php:6421-6425
Score: 60 (moderate)

Evidence:

  • storage/plugins/archives/ArchivesPlugin.php:6421-6425 — archiveSearchPattern() concatenates '%' . $stem . '%' with no % or _ escaping
  • storage/plugins/archives/ArchivesPlugin.php:973 — $q originates from getQueryParams()['q'], only trim()-ed
  • storage/plugins/archives/ArchivesPlugin.php:4127 — sibling searchAuthorityRecordsForTypeahead() correctly uses str_replace(['\','%','_'],...) — clear local precedent
  • Four callers all use bind_param 'ssss' LIKE ? with no ESCAPE clause
  • git log confirms archiveSearchPattern introduced on this PR branch, not pre-existing on main — origin=pre_existing in artifact is incorrect

Approach: In archiveSearchPattern(), escape backslash then % and _ in $q before the stem-trim and '%...%' wrap, mirroring line 4127. All four callers automatically fixed.

Files to modify:

  • storage/plugins/archives/ArchivesPlugin.php — In archiveSearchPattern() body: add str_replace(['\', '%', '_'], ['\\', '\%', '_'], $q) before the stem-trim. Escape first, then substr, to avoid splitting multi-char escape sequences. (why: Restores correctness for queries containing % or _; aligns with sibling and project-wide LIKE escaping convention)

Verification:

  • grep -n 'archiveSearchPattern' storage/plugins/archives/ArchivesPlugin.php — 1 producer + 4 consumers
  • Manual: search 'foo_bar' against seeded units — must NOT match 'fooXbar'

Edge cases to preserve:

  • Italian stemming (strip last char when strlen>=5) applied to escaped string
  • Empty $q: callers short-circuit before calling
  • Backslash escape order: replace backslash first

Latest fix attempt (fixrun_01KRBB2ZT91936NP8ZB0X076FB): fixed and verified

ℹ Uncertain (2)

# Score Impact File Issue
F031 48 security storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2497-2524 requireAdminForDownload() accepts HTTP Basic Auth credentials in-band over potentially unencrypted HTTP connections, transmitting admin passwords in cleartext if TLS is not enforced at the infrastructure level.
F032 58 correctness app/Controllers/FrontendController.php:830-890 FrontendController catalog search builds LIKE patterns by wrapping $wordBase in '%...' without escaping literal % and _ wildcards, unlike AutoriApiController and CollaneController which use str_replace escaping — users searching for underscore or percent get wrong matches on the main book catalog.

Phase 4 couldn't confirm decisively. Re-run /adamsreview:review if you suspect this deserves
further investigation with fresh context.

Fix runs

Run fixrun_01KRBB2ZT91936NP8ZB0X076FB — 2026-05-11T11:17:54Z

  • Outcomes: 9 fixed and verified
  • Commits: 6a93828
Finding Group Outcome phase_9_finding
F002 FG-1 ✓ fixed and verified
F003 FG-2 ✓ fixed and verified
F005 FG-3 ✓ fixed and verified
F007 FG-1 ✓ fixed and verified
F010 FG-4 ✓ fixed and verified
F020 FG-5 ✓ fixed and verified
F023 FG-6 ✓ fixed and verified
F027 FG-7 ✓ fixed and verified
F028 FG-8 ✓ fixed and verified

🤖 Generated with Adam's Claude Code Review Command

@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

Auto-recommendation acceptance

rev_01KRB6B3SXD2MQW9F49YFWA7Z3 · run_id=fixrun_01KRBB2ZT91936NP8ZB0X076FB · choice=apply_all · threshold=60 · reviewer=auto-rec/fabiodalezbackup@gmail.com · ts=2026-05-11T11:02:56Z

6 auto-rec finding(s) eligible at threshold ≥ 60. Of those, 6 auto-promoted via batch, 0 promoted with an edited or alternative hint, 0 skipped per-finding.

Promoted (batch)

  • F005 — Plugin's ensureSchema() defines oai_deleted_records.entity_id as INT NOT NULL and oai_resumption_tokens with a created_at column, but installer/database/schema.sql defines entity_id as BIGINT UNSIGNED and oai_resumption_tokens without a created_at column — schema drift between plugin ensureSchema() and schema.sql.
    • Hint: In OaiPmhServerPlugin.php ensureSchema(), change the oai_deleted_records.entity_id column definition from INT NOT NULL to BIGINT UNSIGNED NOT NULL, and remove the created_at column from the oai_resumption_tokens CREATE TABLE statement, so both definitions match schema.sql exactly.
  • F010 — $archiveResults is computed in the full-page render path but catalogAPI() AJAX path does not compute archive results, so dynamic search-as-you-type never shows archives while the full-page render does.
    • Hint: In FrontendController.php catalogAPI(), replicate the Archives plugin hook call used in catalog() to compute $archiveResults, then merge those results into the JSON response payload (e.g., under an 'archives' key) so search-as-you-type returns the same data as the full-page render.
  • F020 — The Aggiungi button gives no in-progress feedback: it is not disabled and shows no loading indicator while the fetch is in flight, leaving the user able to double-submit.
    • Hint: In the Aggiungi button fetch handler in book-digital-assets.php, immediately before the fetch() call set btn.disabled = true and add a CSS loading/spinner class to the button, then in a finally block after the fetch resolves or rejects restore btn.disabled = false and remove the loading class — matching the pattern used in other forms (e.g., loan forms) in the codebase.
  • F023 — The Indietro back button uses history.back(), which navigates to whatever the previous browser history entry was; if the user arrived via a bookmark or external link, the button is broken.
    • Hint: Replace the bare history.back() call in show.php with a fallback: if document.referrer is non-empty and starts with window.location.origin use history.back(), otherwise assign window.location.href to the authorities list URL (e.g., the value of the route_path('archives_authorities') helper rendered into a data attribute on the button).
  • F027 — The Z39 server quote_search_terms checkbox label is not associated with the checkbox via a for/id pair, so clicking the label text does not toggle the checkbox and screen readers cannot programmatically connect the two elements.
    • Hint: In app/Views/admin/plugins.php around lines 1115-1118, add id='z39_quote_search_terms' to the checkbox input element and for='z39_quote_search_terms' to its sibling label element, mirroring the for/id pattern used on all other plugin setting checkboxes in the same file.
  • F028 — The iiif_manifest_url field is saved through the form but never displayed in the admin detail view, so the admin cannot verify the stored IIIF manifest URL without re-opening the edit form.
    • Hint: In storage/plugins/archives/views/show.php within the detail view block (lines 399-430), add a conditionally-rendered row for iiif_manifest_url using the same pattern as digital_object_url: show the label and the value as an anchor with target='_blank', wrapped in a null/empty check so the row is omitted when the field is unset.

Auto-recommendation acceptance: append-only audit. Each /adamsreview:fix run posts a fresh entry. Promoted findings are now confirmed_mechanical with human_confirmation set; Phase 8 dispatches them next.

Fix groups (committed):
- [FG-1] F002, F007 — storage/plugins/archives/ArchivesPlugin.php: verified
- [FG-2] F003 — storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php: verified
- [FG-3] F005 — storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php: verified
- [FG-4] F010 — app/Controllers/FrontendController.php: verified
- [FG-5] F020 — storage/plugins/oai-pmh-server/views/book-digital-assets.php: verified
- [FG-6] F023 — storage/plugins/archives/views/authorities/show.php: verified
- [FG-7] F027 — app/Views/admin/plugins.php: verified
- [FG-8] F028 — storage/plugins/archives/views/show.php: verified

Post-fix review: 8/8 groups verified complete; 0 group(s) partial; 0 group(s) reverted.
const TEST_AUDIO = path.join(os.tmpdir(), 'archive-test-audio.wav');

function ensureAudioFixture() {
fs.writeFileSync(TEST_AUDIO, Buffer.from([
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

fabiodalez-dev commented May 11, 2026

Code review

Branch: feat/fr-bnf-integrationmain
PR: #132 (open)
Review ID: rev_01KRBD395Z1ABNPQ3WQ1R8X945
Sub-agent tokens: 1,304,012 across 22 invocations

Found 47 findings across all lanes:

  • Deep lane (correctness/security): 8 resolved, 2 uncertain

Deep lane — correctness & security

✓ Auto-fixable (8)

# Score Impact File Issue Status
F003 82 correctness storage/plugins/z39-server/classes/SRUServer.php:1048-1060 errorResponse() builds child elements with createElement() while all other response methods use createElementNS(NS_SRU,...) — error responses will have children in no namespace ✓ fixed and verified (461083b)
F018 88 correctness storage/plugins/z39-server/classes/SruClient.php:708-711 UNIMARC 461 $t (series title) written to numero_serie (volume number / UNSIGNED INT); series title should go to collana/series field ✓ fixed and verified (461083b)
F019 78 correctness storage/plugins/openurl-resolver/OpenUrlResolverPlugin.php:624-634 localBookUrl() hardcodes '/libro/{id}' instead of RouteTranslator::route('book'); en_US installs get 404 ✓ fixed and verified (461083b)
F020 62 correctness storage/plugins/resource-sync/ResourceSyncPlugin.php:308-321 fetchChangedBooks() (no-since) SELECT from libri without deleted_at IS NULL — leaks soft-deleted book IDs to harvesters and violates CLAUDE.md rule (human-confirmed) ✓ fixed and verified (461083b)
F024 70 correctness storage/plugins/ncip-server/NcipServerPlugin.php:1211-1213 UPDATE libri in createLoanAtomic() missing AND deleted_at IS NULL — violates CLAUDE.md absolute soft-delete rule ✓ fixed and verified (461083b)
F025 78 correctness storage/plugins/ncip-server/NcipServerPlugin.php:1322-1324 UPDATE libri in closeLoan() missing AND deleted_at IS NULL — violates CLAUDE.md absolute soft-delete rule ✓ fixed and verified (461083b)
F027 72 correctness storage/plugins/viaf-authority/ViafAuthorityPlugin.php:51-55 ensureSchema() returns void instead of array{created,failed}; onActivate() cannot check $result['failed'] per Plugin Schema Rule ✓ fixed and verified (461083b)
F045 62 security storage/plugins/ncip-server/NcipServerPlugin.php:380-383 requireAdmin() checks only tipo_utente === 'admin'; plugin docs state 'admin/staff' can access write endpoints — privilege mismatch ✓ fixed and verified (461083b)

Cross-cutting group G1: F024 + F025 — Both findings are the same soft-delete invariant violation in the same file (NcipServerPlugin.php), targeting two UPDATE libri statements in adjacent transactional flows (createLoanAtomic at line 1212 and closeLoan at line 1323). They must be patched together in a single edit pass to ensure the loan create/close lifecycle remains consistent and both WHERE clauses are updated atomically.

Details and fix proposals

F003 — errorResponse() builds child elements with createElement() while all other response methods use createElementNS(NS_SRU,...) — error responses will have children in no namespace

File: storage/plugins/z39-server/classes/SRUServer.php:1048-1060
Score: 82 (strong)

Evidence:

  • SRUServer.php:1056 — root uses createElementNS(self::NS_SRU, $rootElement), correctly namespaced
  • SRUServer.php:1059 — $versionEl created with createElement('version') (no namespace)
  • SRUServer.php:1062 — $diagnostics created with createElement('diagnostics') (no namespace)
  • SRUServer.php:1065 — $diagnostic created with createElement('diagnostic') (no namespace)
  • SRUServer.php:1068 — $uri created with createElement('uri') (no namespace)
  • SRUServer.php:1071 — $details created with createElement('details') (no namespace)
  • SRUServer.php:1074 — $messageEl created with createElement('message') (no namespace)
  • SRUServer.php:85 — NS_SRU = 'http://www.loc.gov/zing/srw/'
  • SRUServer.php:797-863 — searchRetrieveResponse() consistently uses createElementNS($ns,...) for every child (FIX 1 comment)
  • SRUServer.php:1003-1033 — scanResponse() also uses createElementNS for every child (FIX 1b comment)
  • 14 callsites of errorResponse() across handleRequest, searchRetrieve, and scan

Approach: Add $ns = self::NS_SRU; and replace every createElement() child with createElementNS($ns, ...) in errorResponse(), mirroring the FIX 1 / FIX 1b pattern in sibling methods.

Files to modify:

  • storage/plugins/z39-server/classes/SRUServer.php — In errorResponse(): add $ns = self::NS_SRU after appending root; change createElement('version') -> createElementNS($ns,'version'); same for diagnostics, diagnostic, uri, details, message. Add // FIX 1c comment. (why: Without namespaced children, serialized XML emits elements with empty-namespace reset, breaking XPath under srw: prefix and SRU XSD validation. Sibling methods already document this fix; errorResponse() was missed.)

Verification:

  • Trigger an error response via curl and inspect with xmllint --format
  • Confirm every element prints with srw: prefix, no xmlns='' reset
  • Run xmllint --noout --schema sru-1.2.xsd response.xml

Edge cases to preserve:

  • $operation match for root element name selection
  • NS_DIAG concatenation in $uri text content (the URI value, not the element namespace)
  • escapeXml() calls on $message and $version

Latest fix attempt (fixrun_01KRBHK6KSS54SJQP08FVWQ8C4): fixed and verified

F018 — UNIMARC 461 $t (series title) written to numero_serie (volume number / UNSIGNED INT); series title should go to collana/series field

File: storage/plugins/z39-server/classes/SruClient.php:708-711
Score: 88 (strong)

Evidence:

  • SruClient.php:558 — documentation comment correctly states '461 $t = series title' per UNIMARC spec
  • SruClient.php:707-711 — code comment says '461 $t' but assigns title value to $book['numero_serie'] (the volume-number field)
  • schema.sql:420-421 — collana varchar(100) (series title) and numero_serie varchar(50) (volume number); mismatch confirmed
  • book_form.php:525-534 — UI: collana='Serie principale', numero_serie='Numero Serie' with placeholder 'es. 15' (numeric)
  • BookRepository.php:109-113 — UNIMARCXMLFormatter exports collana->225 $a and numero_serie->225 $v confirming convention
  • BookRepository.php:1080-1095 — saveFromScrape() regex splits series into collana (name) + numero_serie (digit-only number)
  • SruClient.php — no parsing of 461 $v (volume) anywhere; volume number from BnF silently dropped

Approach: Map UNIMARC 461 $t to collana (series title) and 461 $v to numero_serie (volume number); fallback to 225 $a/$v when 461 absent; truncate to varchar(50) defensively.

Files to modify:

  • storage/plugins/z39-server/classes/SruClient.php — Replace lines 707-711 with: $seriesTitle = $getSub('461','t') ?? $getSub('225','a'); if ($seriesTitle !== null) $book['collana'] = $clean($seriesTitle); $seriesVol = $getSub('461','v') ?? $getSub('225','v'); if ($seriesVol !== null) $book['numero_serie'] = mb_substr($clean($seriesVol),0,50); (why: Aligns parser with UNIMARC spec (in-file comment at line 558) and UNIMARCXMLFormatter convention (BookRepository:109-113); restores round-trip integrity for BnF imports)

Verification:

  • Feed parseMarcxchangeXml fixture with 461 $t='La Pléiade' 461 $v='42' — assert collana==='La Pléiade' and numero_serie==='42'
  • Repeat with 225 $a/$v fallback
  • Run E2E BnF SRU import test

Edge cases to preserve:

  • 461 absent + 225 absent → no writes
  • Non-numeric volume values fit in varchar(50)
  • Empty string after clean() → skip assignment

Latest fix attempt (fixrun_01KRBHK6KSS54SJQP08FVWQ8C4): fixed and verified

F019 — localBookUrl() hardcodes '/libro/{id}' instead of RouteTranslator::route('book'); en_US installs get 404

File: storage/plugins/openurl-resolver/OpenUrlResolverPlugin.php:624-634
Score: 78 (strong)

Evidence:

  • OpenUrlResolverPlugin.php:375 — return $origin . $basePath . '/libro/' . $bookId; hardcoded Italian path
  • routes_en_US.json — book route is /book, not /libro
  • routes_de_DE.json — book route is /buch
  • app/Routes/web.php:2329 — book routes registered per-locale in supportedLocales loop; /libro not registered on en_US-only installs
  • app/helpers.php:211-218 — book_url($book) returns canonical SEO URL /{authorSlug}/{bookSlug}/{id}; used by NotificationService, SearchController, FrontendController
  • CLAUDE.md rule Add z39-server plugin tables to installer schema #4 — no hardcoded locale-specific paths

Approach: Replace localBookUrl(request, int $bookId) with localBookUrl(request, array $book) and use book_url($book) instead of manual /libro/ concatenation; prepend only $origin (scheme://host[:port]) since book_url() already includes base path.

Files to modify:

  • storage/plugins/openurl-resolver/OpenUrlResolverPlugin.php — Change signature to localBookUrl(ServerRequestInterface $request, array $book). Build $origin from $request->getUri(). Return $origin . book_url($book). Remove $basePath . '/libro/' . $bookId concatenation. Update call site at resolveAction() to pass $book array. (why: book_url() is the codebase-wide canonical helper that respects locale, avoids /libro vs /book vs /buch split, and eliminates 404 on non-IT installs)

Verification:

  • On en_US-only install: GET /openurl?rft.isbn= — confirm 302 Location resolves to 200 (not 404)
  • On it_IT install: confirm book_url-based URL still resolves
  • phpstan analyse storage/plugins/openurl-resolver/

Edge cases to preserve:

  • Port handling: strip default ports, keep non-standard
  • Subfolder installs: book_url() already includes BASE_PATH — do NOT also prepend $basePath
  • book_url() falls back to generic slugs when title/author missing

Latest fix attempt (fixrun_01KRBHK6KSS54SJQP08FVWQ8C4): fixed and verified

F020 — fetchChangedBooks() (no-since) SELECT from libri without deleted_at IS NULL — leaks soft-deleted book IDs to harvesters and violates CLAUDE.md rule

File: storage/plugins/resource-sync/ResourceSyncPlugin.php:308-321
Score: 62 (moderate)
Human-confirmed: @auto-rec/fabiodalezbackup@gmail.com at 2026-05-11T12:55:03Z — auto-accepted via :fix Phase 7.5 preflight
Promoted from disposition=manual / actionability=manual / score_phase4=62
Fix direction: In fetchChangedBooks() no-since branch, replace the bare SELECT with WHERE (deleted_at IS NULL OR deleted_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)) so only live books and recent tombstones (≤30 days) are returned to harvesters. Add an inline comment explaining the 30-day window is an intentional tombstone exception to the CLAUDE.md soft-delete rule, required for ResourceSync protocol compliance. Leave the since-branch, fetchBooks(), and buildChangeList() unchanged.

Evidence:

  • ResourceSyncPlugin.php:491-495 — no-since branch: SELECT id, updated_at, created_at, deleted_at FROM libri ORDER BY COALESCE(deleted_at,updated_at,created_at) DESC — no deleted_at IS NULL, no time bound
  • ResourceSyncPlugin.php:474-489 — since-branch has comment 'Include deleted records so harvesters can remove them' — intentional tombstone advertising
  • ResourceSyncPlugin.php:412-420 — buildChangeList maps deleted_at !== null to rs:md change='deleted'
  • ResourceSyncPlugin.php:443-462 — fetchBooks() ResourceList DOES correctly filter deleted_at IS NULL
  • CLAUDE.md §2 — every libri query must include deleted_at IS NULL

Approach: Two options: (a) Strict CLAUDE.md compliance — add WHERE deleted_at IS NULL to no-since branch, accept no tombstones without ?from; (b) Bounded tombstone mode — add WHERE updated_at >= DATE_SUB(NOW(),INTERVAL 30 DAY) OR deleted_at >= DATE_SUB(NOW(),INTERVAL 30 DAY) to bound tombstone window. Recommend (b) for protocol alignment. Since-branch unchanged.

Files to modify:

  • storage/plugins/resource-sync/ResourceSyncPlugin.php — In fetchChangedBooks() no-since branch: add time-bounded WHERE clause (30-day window) or strict deleted_at IS NULL per maintainer preference. Add explanatory comment citing ResourceSync ChangeList tombstone semantics. (why: Bounds the leak surface (no full historical deletion dump) and documents the intentional tombstone exception to CLAUDE.md rule)

Verification:

  • Soft-delete a book and curl /resync/changelist.xml (no ?from): verify behavior per chosen policy
  • curl /resync/changelist.xml?from=2020-01-01 — tombstones still appear (since-branch unchanged)
  • grep 'FROM libri' in plugin — each match has deleted_at IS NULL or intentional tombstone clause

Edge cases to preserve:

  • since-branch tombstone behavior must not be touched
  • fetchBooks() ResourceList must continue to exclude deleted
  • buildChangeList() deleted_at handling

Latest fix attempt (fixrun_01KRBHK6KSS54SJQP08FVWQ8C4): fixed and verified

F024 — UPDATE libri in createLoanAtomic() missing AND deleted_at IS NULL — violates CLAUDE.md absolute soft-delete rule

File: storage/plugins/ncip-server/NcipServerPlugin.php:1211-1213
Score: 70 (moderate)

Evidence:

  • NcipServerPlugin.php:1211-1213 — UPDATE libri SET copie_disponibili = GREATEST(0, copie_disponibili - 1) WHERE id = ? — no AND deleted_at IS NULL
  • NcipServerPlugin.php:1183 — preceding SELECT FOR UPDATE DOES include deleted_at IS NULL (partial mitigation in same transaction)
  • NcipServerPlugin.php:1112 — fetchBook() correctly includes deleted_at IS NULL
  • CLAUDE.md §2 — absolute rule, no carve-outs

Approach: Add AND deleted_at IS NULL to both UPDATE libri WHERE clauses (F024 at line 1212 and F025 at line 1323) in a single patch.

Files to modify:

  • storage/plugins/ncip-server/NcipServerPlugin.php — Line 1212: append AND deleted_at IS NULL to WHERE id = ?. Update bind_param to add extra NULL bind or restructure as needed. (why: CLAUDE.md absolute rule §2; defense-in-depth even with FOR UPDATE preceding guard)

Verification:

  • grep -n 'UPDATE libri' storage/plugins/ncip-server/NcipServerPlugin.php — both must include AND deleted_at IS NULL
  • E2E: NCIP CheckOut on soft-deleted book — must fail cleanly

Edge cases to preserve:

  • affected_rows === 1 check still works (returns 0 for soft-deleted, triggers rollback)
  • GREATEST/LEAST clamps unchanged

Latest fix attempt (fixrun_01KRBHK6KSS54SJQP08FVWQ8C4): fixed and verified

F025 — UPDATE libri in closeLoan() missing AND deleted_at IS NULL — violates CLAUDE.md absolute soft-delete rule

File: storage/plugins/ncip-server/NcipServerPlugin.php:1322-1324
Score: 78 (strong)

Evidence:

  • NcipServerPlugin.php:1322-1324 — UPDATE libri SET copie_disponibili = LEAST(copie_totali, copie_disponibili + 1) WHERE id = ? — no AND deleted_at IS NULL
  • NcipServerPlugin.php:1306-1338 — closeLoan() has NO preceding SELECT on libri that filters deleted_at — unlike createLoanAtomic()
  • Bug is reachable in practice: book soft-deleted while NCIP loan is open
  • CLAUDE.md §2 absolute rule

Approach: Same combined patch as F024 — add AND deleted_at IS NULL to WHERE clause at line 1323.

Files to modify:

  • storage/plugins/ncip-server/NcipServerPlugin.php — Line 1323: append AND deleted_at IS NULL to WHERE id = ? (why: No preceding FOR UPDATE guard in closeLoan() — deletion between checkout and checkin is a real reachable case; affected_rows === 1 check will then roll back correctly)

Verification:

  • Create NCIP loan; soft-delete book; send CheckIn — assert rollback, copie_disponibili unchanged
  • grep -n 'UPDATE libri' must show AND deleted_at IS NULL on both lines

Edge cases to preserve:

  • LEAST(copie_totali, copie_disponibili + 1) cap unchanged
  • Atomic rollback of prestiti row when libri update fails

Latest fix attempt (fixrun_01KRBHK6KSS54SJQP08FVWQ8C4): fixed and verified

F027 — ensureSchema() returns void instead of array{created,failed}; onActivate() cannot check $result['failed'] per Plugin Schema Rule

File: storage/plugins/viaf-authority/ViafAuthorityPlugin.php:51-55
Score: 72 (moderate)

Evidence:

  • ViafAuthorityPlugin.php:51 — public function ensureSchema(): void — returns void, not array{created,failed}
  • ViafAuthorityPlugin.php:57-68 — onActivate() calls $this->ensureSchema() discarding return value, no $result['failed'] check
  • ViafAuthorityPlugin.php:70-73 — onInstall() also discards contract
  • OaiPmhServerPlugin.php:108-114 — reference: ensureSchema() returns array{created:list,failed:list}
  • OaiPmhServerPlugin.php:70-87 — reference onActivate() checks $result['failed'] and throws
  • CLAUDE.md Plugin Schema Rule — ensureSchema() must return array{created,failed}; onActivate must throw on $result['failed']

Approach: Change ensureSchema() to return array{created:list,failed:list}; wrap ensureSchemaColumns() and ensureAlternatesTable() calls in try/catch Throwable blocks, accumulating created/failed lists. Update onActivate() and onInstall() to check $result['failed'] and throw RuntimeException. Mirror OaiPmhServerPlugin pattern exactly.

Files to modify:

  • storage/plugins/viaf-authority/ViafAuthorityPlugin.php — Change ensureSchema(): void to ensureSchema(): array with @return array{created:list,failed:list}. Wrap sub-calls in try/catch, build created/failed lists. Update both onActivate() and onInstall() to: $result = $this->ensureSchema(); if (!empty($result['failed'])) throw new \RuntimeException('[ViafAuthority] Schema activation failed: '.implode(', ',$result['failed'])); (why: Brings ViafAuthorityPlugin into conformity with CLAUDE.md Plugin Schema Rule; aligns with reference plugins; surfaces aggregated error messages)

Verification:

  • phpstan analyse --memory-limit=512M storage/plugins/viaf-authority/
  • Activate on fresh DB — verify columns and alternates table created; onActivate completes
  • Re-activate — verify idempotency (no errors)
  • Activate with simulated DDL failure — verify RuntimeException with 'Schema activation failed' message

Edge cases to preserve:

  • Idempotency: IF NOT EXISTS guards on column adds must stay
  • ensureSchema() must be called OUTSIDE begin_transaction() (DDL implicit-commits)
  • Backfill of legacy source_code/source_id columns

Latest fix attempt (fixrun_01KRBHK6KSS54SJQP08FVWQ8C4): fixed and verified

F045 — requireAdmin() checks only tipo_utente === 'admin'; plugin docs state 'admin/staff' can access write endpoints — privilege mismatch

File: storage/plugins/ncip-server/NcipServerPlugin.php:380-383
Score: 62 (moderate)

Evidence:

  • NcipServerPlugin.php:28 — docblock: 'Only users with tipo_utente IN (admin,staff) may perform write operations'
  • NcipServerPlugin.php:380-384 — requireAdmin() returns true only when tipo_utente === 'admin'
  • NcipServerPlugin.php:1415-1420 — isStaff() already correctly checks in_array(role,['admin','staff'],true) for NCIP protocol writes
  • AdminAuthMiddleware.php:47 — app-wide gate uses tipo_utente !== 'admin' && tipo_utente !== 'staff'
  • OaiPmhServerPlugin.php:2858-2862 — sibling requireAdminOrStaff() uses in_array(['admin','staff']) for analogous endpoints

Approach: Rename requireAdmin() to requireAdminOrStaff() and broaden role check to in_array(tipo_utente, ['admin','staff'], true). Update 4 call sites. Optionally reuse existing isStaff() helper.

Files to modify:

  • storage/plugins/ncip-server/NcipServerPlugin.php — Replace requireAdmin() body with: return isset($_SESSION['user']) && in_array($_SESSION['user']['tipo_utente'] ?? '', ['admin','staff'], true); rename to requireAdminOrStaff(). Update call sites at lines 258, 278, 314, 338. (why: Aligns admin UI authorization with plugin docblock, isStaff() helper, AdminAuthMiddleware, and sibling OaiPmhServerPlugin.requireAdminOrStaff())

Verification:

  • Log in as tipo_utente='staff': GET /admin/plugins/ncip-server/partners → 200, POST partners → 302 success
  • Log in as tipo_utente='utente': all 4 endpoints still denied
  • phpstan level 5 clean

Edge cases to preserve:

  • isset() guard for anonymous
  • ?? '' default for missing tipo_utente
  • CSRF validation in create/delete handlers unchanged

Latest fix attempt (fixrun_01KRBHK6KSS54SJQP08FVWQ8C4): fixed and verified

ℹ Uncertain (2)

# Score Impact File Issue
F001 55 correctness storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2785-2793 digitalAssetAddAction binds $filesize (int) with type-char 's' in bind_param('issssiii'), not 'i'; $filesize is a BIGINT UNSIGNED column
F002 58 correctness app/Views/frontend/book-detail.php:180-184 sameAs is unconditionally assigned even when $sameAsLinks is empty [], injecting sameAs:[] into Schema.org output rather than omitting the key

Phase 4 couldn't confirm decisively. Re-run /adamsreview:review if you suspect this deserves
further investigation with fresh context.

Fix runs

Run fixrun_01KRBHK6KSS54SJQP08FVWQ8C4 — 2026-05-11T13:07:54Z

  • Outcomes: 11 fixed and verified
  • Commits: 461083b
Finding Group Outcome phase_9_finding
F003 FG-1 ✓ fixed and verified
F018 FG-2 ✓ fixed and verified
F019 FG-3 ✓ fixed and verified
F020 FG-4 ✓ fixed and verified
F023 FG-5 ✓ fixed and verified
F024 FG-6 ✓ fixed and verified
F025 FG-6 ✓ fixed and verified
F027 FG-7 ✓ fixed and verified
F028 FG-8 ✓ fixed and verified
F029 FG-9 ✓ fixed and verified
F045 FG-6 ✓ fixed and verified

🤖 Generated with Adam's Claude Code Review Command

@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

Auto-recommendation acceptance

rev_01KRBD395Z1ABNPQ3WQ1R8X945 · run_id=fixrun_01KRBHK6KSS54SJQP08FVWQ8C4 · choice=apply_all · threshold=60 · reviewer=auto-rec/fabiodalezbackup@gmail.com · ts=2026-05-11T12:55:39Z

4 auto-rec finding(s) eligible at threshold ≥ 60. Of those, 4 auto-promoted via batch, 0 promoted with edited/alternative hint, 0 skipped per-finding.

Promoted (batch)

  • F020 — fetchChangedBooks() (no-since) SELECT from libri without deleted_at IS NULL — leaks soft-deleted book IDs to harvesters and violates CLAUDE.md rule
    • Hint: In fetchChangedBooks()no-since branch, replace the bareSELECTwithWHERE (deleted_at IS NULL OR deleted_at >= DATE_SUB(NOW(), INTERVAL 30 DAY))so only live books and recent tombstones (≤30 days) are returned to harvesters. Add an inline comment explaining the 30-day window is an intentional tombstone exception to the CLAUDE.md soft-delete rule, required for ResourceSync protocol compliance. Leave the since-branch,fetchBooks(), and buildChangeList() unchanged.
  • F023 — session_write_close() before Hook dispatch; Hook callbacks reading $_SESSION get closed-session snapshot, cannot see concurrent session updates
    • Hint: This is an architectural trade-off, not a mechanical fix: session_write_close()is intentionally placed before Hook dispatch to release the session lock and avoid blocking concurrent requests. Update the inline comment at line 82 to explicitly state that Hook callbacks invoked from this point forward MUST NOT read or write$_SESSION, and add a note to the Hook registration documentation (or a @session-unsafe tag on the hook name) so future callback authors are warned at the point of registration rather than at runtime.
  • F028 — ensureSchema() docblock says errors do not abort activation (partial success acceptable) but new migrateImageColumns()/migrateArchivalUnitFilesFK() throw RuntimeException, contradicting docblock
    • Hint: Update the ensureSchema()docblock (lines 237-244) to accurately state that migration failures (frommigrateImageColumns()andmigrateArchivalUnitFilesFK()) throw RuntimeException and therefore DO abort activation — the partial-success semantic described in the current docblock no longer applies. This is a one-line docblock correction that brings documentation into sync with actual behavior.
  • F029 — Class docblock lists four OAI-PMH metadata formats (oai_dc, marcxml, mods, mag) but code now also advertises/serves 'unimarc'
    • Hint: Add 'unimarc' to the class docblock's list of advertised metadata formats (lines 22-36) so the documentation matches the four original formats plus the newly served one. No code change is needed — this is a docblock-only update.

Auto-recommendation acceptance: append-only audit. Promoted findings are now confirmed_mechanical with human_confirmation set; Phase 8 dispatches them next.

Fix groups (committed):
- [FG-1] F003 — storage/plugins/z39-server/classes/SRUServer.php: verified
- [FG-2] F018 — storage/plugins/z39-server/classes/SruClient.php: verified
- [FG-3] F019 — storage/plugins/openurl-resolver/OpenUrlResolverPlugin.php: verified
- [FG-4] F020 — storage/plugins/resource-sync/ResourceSyncPlugin.php: verified
- [FG-5] F023 — app/Controllers/ScrapeController.php: verified
- [FG-6] F024, F025, F045 — storage/plugins/ncip-server/NcipServerPlugin.php: verified
- [FG-7] F027 — storage/plugins/viaf-authority/ViafAuthorityPlugin.php: verified
- [FG-8] F028 — storage/plugins/archives/ArchivesPlugin.php: verified
- [FG-9] F029 — storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php: verified

Post-fix review: 9/9 groups verified complete; 0 group(s) partial; 0 group(s) reverted.
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

Walkthrough decisions — 2026-05-11T13:15:03Z

Reviewer: fabiodalezbackup@gmail.com
Score floor: 60
Tier chosen: none (walk scope empty at threshold 60)
Findings walked: 0
Pre-existing findings: 1


Pre-existing findings

ID File Claim Action
F048 app/Views/frontend/book-detail.php:1623 ucfirst($author['ruolo']) echoed without htmlspecialchars — stored XSS if ruolo holds attacker-controlled data Fixed directly — added htmlspecialchars(ucfirst(...), ENT_QUOTES, 'UTF-8')

Generated by /adamsreview:walkthrough (threshold=60)

@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

fabiodalez-dev commented May 11, 2026

Code review

Branch: feat/fr-bnf-integrationmain
PR: #132 (open)
Review ID: rev_01KRCFVJ2J4S9RJ989JXT3FJY5
Sub-agent tokens: 1,642,788 across 29 invocations

Found 34 findings across all lanes:

  • Deep lane (correctness/security): 6 resolved

Deep lane — correctness & security

✓ Auto-fixable (6)

# Score Impact File Issue Status
F007 82 correctness storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:597-614 earliestDatestamp in oaiIdentify uses DateTime::createFromFormat with no timezone — parses MySQL datetime in PHP's default TZ, then format('Y-m-d\TH:i:s\Z') appends Z literally; emitted datestamp is local time mislabelled as UTC. recordDatestamp() in the same file correctly uses strtotime+gmdate; this is the outlier. ✓ fixed and verified (332f723)
F008 55 correctness app/Controllers/FrontendController.php:742-747 bookDetail() issues SELECT 1 FROM plugins on every render — N+1 DB round-trip on the hottest page. No caching; runs even for anonymous catalog crawls. (human-confirmed) ✓ fixed and verified (27d51ad)
F012 82 correctness storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2500-2520 writeBookMag calls fetchDigitalAsset per record — the batch path in fetchRecordsPage does NOT pre-fetch digital_assets, so every MAG record on a page triggers an extra prepared-statement query (100+ queries per page at PAGE_SIZE=100). ✓ fixed and verified (332f723)
F018 55 correctness app/Support/Updater.php:2245-2253 Path-traversal guard uses prefix-only comparison strpos($parentTarget, $realDest) !== 0; a target resolving to '/var/www/destination/x' passes when $realDest='/var/www/dest' because the prefix matches — original vulnerability persists. (human-confirmed) ✓ fixed and verified (27d51ad)
F040 55 security storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2497-2524 requireAdminForDownload returns HTTP 403 when no session or Basic Auth credentials are present, instead of 401 with WWW-Authenticate; prevents HTTP clients from discovering that Basic Auth is supported. (human-confirmed) ✓ fixed and verified (27d51ad)
F041 86 correctness storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:1147-1634 MARCXML formatter at line 1147-1148 uses date('YmdHis', $ts) (local timezone) for the MARC 005 control field; MODS formatter at line 1631-1634 uses date('Y-m-d', $ts) (local timezone) for recordCreationDate. Both should use gmdate() — the same file already uses gmdate() correctly in multiple places (e.g. line 436, 2371). For European installs (Europe/Rome: UTC+1/+2), exported MARCXML and MODS records carry local wall-clock time instead of UTC, causing off-by-one-to-two-hour drift in MARC 005 and off-by-one-day errors at midnight boundaries in recordCreationDate. ✓ fixed and verified (332f723)

Cross-cutting group G1: F007 + F041 — Both findings are manifestations of the same root cause: inconsistent local-timezone date emission inside OaiPmhServerPlugin.php. Fix together as a single 'normalize all catalog date emissions to UTC' pass: convert all five sites to strtotime()+gmdate() pattern (lines 596-601, 610-615 for F007; lines 1148, 1153, 1634 for F041), while explicitly preserving line 2211 (resumption-token expires_at). The UNIMARC sibling at line 1348 already uses gmdate() — use it as the canonical pattern throughout.

Details and fix proposals

F007 — earliestDatestamp in oaiIdentify uses DateTime::createFromFormat with no timezone — parses MySQL datetime in PHP's default TZ, then format('Y-m-d\TH:i:s\Z') appends Z literally; emitted datestamp is local time mislabelled as UTC. recordDatestamp() in the same file correctly uses strtotime+gmdate; this is the outlier.

File: storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:597-614
Score: 82 (strong)

Evidence:

  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:597 — DateTime::createFromFormat('Y-m-d H:i:s', $row['e']) with no timezone argument uses PHP date.timezone
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:599 — format('Y-m-dTH:i:sZ') uses escaped \Z (literal character), NOT UTC conversion — local wall-clock with literal 'Z' suffix
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:611-613 — identical bug duplicated for archival_units table
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2371-2377 — recordDatestamp() uses strtotime()+gmdate() correctly (UTC); earliestDatestamp is the outlier
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:436 — responseDate uses gmdate() correctly
  • git log confirms: pattern introduced by commit ce4237b on current PR branch (origin: introduced_by_pr confirmed)
  • Tests call verb=Identify but only check structure/well-formedness, not UTC correctness of earliestDatestamp value

Approach: Replace DateTime::createFromFormat (no timezone) + escaped \Z suffix with strtotime()+gmdate() pattern, matching the existing correct recordDatestamp() implementation. Apply to both the libri block (lines 596-601) and the archival_units block (610-615).

Files to modify:

  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php — Lines 596-601: replace 'if (!empty($row["e"])) { $dt = DateTime::createFromFormat(...); if ($dt !== false) { $earliest = $dt->format("Y-m-d\TH:i:s\Z"); } }' with 'if (!empty($row["e"])) { $ts = strtotime((string)$row["e"]); if ($ts !== false) { $earliest = gmdate("Y-m-d\TH:i:s\Z", $ts); } }'. Apply identical fix to lines 610-615 for archival_units block. (why: DateTime::createFromFormat without a timezone arg uses PHP date.timezone; the \Z in format() is an escaped literal character, not a UTC conversion — result is local wall-clock time with a literal 'Z' suffix, violating OAI-PMH §2.7.1 and breaking incremental harvesting for harvesters in non-UTC timezones)

Verification:

  • Set PHP default tz to Europe/Rome and a known libri.created_at='2026-01-15 10:00:00'; before fix: GET /oai?verb=Identify yields 2026-01-15T10:00:00Z (wrong — local wall clock); after fix: should match UTC equivalent
  • Cross-check: pick the book with earliest created_at, fetch GetRecord for it, compare its with Identify's — must be consistent
  • Add regression test asserting earliestDatestamp matches gmdate('Y-m-dTH:i:sZ', strtotime())

Edge cases to preserve:

  • Empty libri AND empty archival_units → static '1970-01-01T00:00:00Z' fallback must still apply
  • Only archival_units populated → archives min must still win and be UTC-correct
  • Both populated → lexicographic comparison of UTC strings remains semantically correct since both are now same format and timezone

Latest fix attempt (fixrun_01KRDDAGWPXE3XMZNNRSRW3ATQ): fixed and verified

F008 — bookDetail() issues SELECT 1 FROM plugins on every render — N+1 DB round-trip on the hottest page. No caching; runs even for anonymous catalog crawls.

File: app/Controllers/FrontendController.php:742-747
Score: 55
Human-confirmed: @fabiodalezbackup@gmail.com at 2026-05-12T08:08:08Z — Claim confirmed: uncached SELECT 1 FROM plugins on every bookDetail() render (hottest frontend page). Fix: add PluginManager::isActive() with static cache — eliminates copy-paste anti-pattern in FrontendController.php and layout.php.
Promoted from disposition=uncertain / actionability=auto_fixable / score_phase4=55
Fix direction: Add public function isActive(string $name): bool to PluginManager with private static array $cache = []. In isActive(): if isset($cache[$name]) return early, else SELECT is_active FROM plugins WHERE name=? LIMIT 1, cache result. Replace lines 742-747 in FrontendController.php with $bibframePluginActive = $this->container->get(PluginManager::class)->isActive('bibframe-linked-data'). Also fix layout.php:79 (identical uncached query for archives plugin). One DB hit per process lifetime per plugin.

Latest fix attempt (fixrun_01KRDP7GN6SQCYKJFXDD38TMKD): fixed and verified

F012 — writeBookMag calls fetchDigitalAsset per record — the batch path in fetchRecordsPage does NOT pre-fetch digital_assets, so every MAG record on a page triggers an extra prepared-statement query (100+ queries per page at PAGE_SIZE=100).

File: storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2500-2520
Score: 82 (strong)

Evidence:

  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:47 — PAGE_SIZE = 100
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:1740 — $asset = $this->fetchDigitalAsset((int)$row['id']) called unconditionally inside writeBookMag; no pre-fetched _digital_asset key check
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2337-2350 — fetchDigitalAsset() runs a fresh prepare/execute/close per call
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:1929-2192 — fetchRecordsPage() batch-fetches authors, publishers, genres, mag_config but never digital_assets
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2176 — comment 'Attach pre-fetched related data to avoid N+1 queries in writeMetadata()' — design intent is clear, digital_assets was missed
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:783 — 'mag' in validPrefixes for ListRecords — batch path is exercised in production

Approach: Add a 6th batch-fetch of digital_assets in fetchRecordsPage() alongside the existing 5 batched fetches; stamp _digital_asset on each row; update writeBookMag() to consume _digital_asset via array_key_exists() — mirroring the _authors/_publisher/_genre/_mag_config pattern already in the function.

Files to modify:

  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php — In fetchRecordsPage() after line ~2160 (existing genre batch): add batch SELECT of digital_assets WHERE libro_id IN ($ph) ORDER BY libro_id, id; build assetMap[$libroId]=first-row (ORDER BY libro_id, id + first-wins replicates existing LIMIT 1 ORDER BY id semantics). In reassembly loop (~line 2180): add $row['_digital_asset'] = $assetMap[$id] ?? null. In writeBookMag() line 1740: replace $asset=$this->fetchDigitalAsset((int)$row['id']) with $asset = array_key_exists('_digital_asset', $row) ? (is_array($row['_digital_asset']) ? $row['_digital_asset'] : null) : $this->fetchDigitalAsset((int)$row['id']) (why: writeBookMag() calls fetchDigitalAsset() per book (1 fresh prepare/execute/close per book), adding 100 DB round-trips per page of PAGE_SIZE=100 — defeating the explicit N+1-prevention batch-fetch pattern at line 2176)

Verification:

  • Enable mysqli general log; issue GET /oai?verb=ListRecords&metadataPrefix=mag; before fix: prepare/execute counter increments by ~105 per page; after fix: ~6 per page
  • Run E2E oai-pmh-server.spec.js with metadataPrefix=mag and confirm emitted XML is byte-identical to pre-fix output
  • Test edge cases: book with no digital_asset row (legacy file_url fallback), book with multiple digital_assets rows (must still pick lowest id)

Edge cases to preserve:

  • ORDER BY id LIMIT 1 semantics — when multiple digital_assets rows exist, pick the row with the smallest id (oldest insert); batch query must replicate with ORDER BY libro_id, id + first-wins
  • GetRecord (single record) and direct MAG download endpoint — those don't populate _digital_asset, so array_key_exists check must fall back to fetchDigitalAsset
  • Empty page with zero books — skip the new batch when $bookIds is empty

Latest fix attempt (fixrun_01KRDDAGWPXE3XMZNNRSRW3ATQ): fixed and verified

F018 — Path-traversal guard uses prefix-only comparison strpos($parentTarget, $realDest) !== 0; a target resolving to '/var/www/destination/x' passes when $realDest='/var/www/dest' because the prefix matches — original vulnerability persists.

File: app/Support/Updater.php:2245-2253
Score: 55
Human-confirmed: @fabiodalezbackup@gmail.com at 2026-05-12T08:20:41Z — CWE-22 prefix-without-separator confirmed: strpos(path, dest) matches '/var/www/destination' when dest='/var/www/dest'. Fix is trivial and future-proofs any new callers of copyDirectoryRecursive.
Promoted from disposition=uncertain / actionability=auto_fixable / score_phase4=55
Fix direction: Updater.php line 2249: replace strpos($parentTarget, $realDest) !== 0 with $parentTarget !== $realDest && !str_starts_with($parentTarget, $realDest . '/'). Add inline comment: // Append '/' to prevent prefix-collision: '/var/www/dest2' must not pass when $realDest='/var/www/dest'

Latest fix attempt (fixrun_01KRDP7GN6SQCYKJFXDD38TMKD): fixed and verified

F040 — requireAdminForDownload returns HTTP 403 when no session or Basic Auth credentials are present, instead of 401 with WWW-Authenticate; prevents HTTP clients from discovering that Basic Auth is supported.

File: storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2497-2524
Score: 55
Human-confirmed: @fabiodalezbackup@gmail.com at 2026-05-12T08:30:00Z — Confirmed RFC 7235 violation: 403 returned when no credentials present. Library automation clients (ILS, harvesters) need 401+WWW-Authenticate to discover Basic Auth support. Fix: differentiate no-credentials (401) from wrong-credentials (403).
Promoted from disposition=uncertain / actionability=auto_fixable / score_phase4=55
Fix direction: OaiPmhServerPlugin.php requireAdminForDownload(): split the failure path into two cases. If $request is null OR no Authorization header present: $out = $response->withStatus(401)->withHeader('WWW-Authenticate', 'Basic realm="OAI-PMH"'); return false. If Authorization header present but authenticateBasicOai fails: $out = $response->withStatus(403); return false. Check: $auth = $request !== null ? $request->getHeaderLine('Authorization') : ''; if empty($auth): return 401+WWW-Authenticate.

Latest fix attempt (fixrun_01KRDP7GN6SQCYKJFXDD38TMKD): fixed and verified

F041 — MARCXML formatter at line 1147-1148 uses date('YmdHis', $ts) (local timezone) for the MARC 005 control field; MODS formatter at line 1631-1634 uses date('Y-m-d', $ts) (local timezone) for recordCreationDate. Both should use gmdate() — the same file already uses gmdate() correctly in multiple places (e.g. line 436, 2371). For European installs (Europe/Rome: UTC+1/+2), exported MARCXML and MODS records carry local wall-clock time instead of UTC, causing off-by-one-to-two-hour drift in MARC 005 and off-by-one-day errors at midnight boundaries in recordCreationDate.

File: storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:1147-1634
Score: 86 (strong)

Evidence:

  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:1147-1148 — MARC21XML field 005 uses date('YmdHis', $ts), i.e. PHP local timezone; MARC 21 spec treats 005 as UTC
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:1153 — MARC 008 byte 0-5 uses date('ymd'), also local-timezone
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:1631-1634 — MODS recordCreationDate uses date('Y-m-d', $ts), local-timezone with encoding='w3cdtf'
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:1348 — UNIMARC equivalent of MARC 005 correctly uses gmdate('YmdHis') — confirms intended UTC convention, not stylistic choice
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:1361, 1462, 2587, 2592, 2656 — all other MARC date emissions use gmdate(), establishing UTC as the file's convention
  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:436, 902, 2371-2376 — OAI-PMH response envelope timestamps all use gmdate(), spec-compliant

Approach: Replace three local-timezone date() calls with gmdate() in MARC21XML and MODS formatters. Lines 1148, 1153, 1634 only. Do NOT change line 2211 (resumption-token expires_at — paired with MySQL NOW()).

Files to modify:

  • storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php — Line 1148: date('YmdHis', $ts) → gmdate('YmdHis', $ts) for MARC 005. Line 1153: date('ymd') → gmdate('ymd') for MARC 008 date-entered-on-file. Line 1634: date('Y-m-d', $ts) → gmdate('Y-m-d', $ts) for MODS recordCreationDate. (why: UNIMARC sibling at line 1348 already uses gmdate(); MARC 005 is conventionally UTC; MODS w3cdtf encoding implies UTC-derived date; local-tz emits wrong date at midnight and wrong time for non-UTC servers)

Verification:

  • Set PHP timezone to Europe/Rome; GET /oai?verb=GetRecord&identifier=oai:HOST:libri:1&metadataPrefix=marc21 — confirm equals UTC value of updated_at, not local time
  • Same with metadataPrefix=mods — confirm equals UTC date of created_at
  • Cross-check: OAI-PMH header (already UTC) and embedded MARC 005 / MODS recordCreationDate should now agree on the date component
  • Run existing OAI-PMH E2E specs to ensure no timestamp assertions break

Edge cases to preserve:

  • Books with NULL updated_at/created_at: strtotime() → false → ?: time() fallback works identically with gmdate()
  • Do NOT change line 2211 (resumption-token expires_at) — paired with MySQL NOW()
  • Format strings stay identical; only function name changes

Latest fix attempt (fixrun_01KRDDAGWPXE3XMZNNRSRW3ATQ): fixed and verified

Fix runs

Run fixrun_01KRDDAGWPXE3XMZNNRSRW3ATQ — 2026-05-12T07:22:42Z

  • Outcomes: 5 fixed and verified
  • Commits: 332f723
Finding Group Outcome phase_9_finding
F007 FG-1 ✓ fixed and verified
F012 FG-1 ✓ fixed and verified
F031 FG-2 ✓ fixed and verified
F034 FG-3 ✓ fixed and verified
F041 FG-1 ✓ fixed and verified

Run fixrun_01KRDP7GN6SQCYKJFXDD38TMKD — 2026-05-12T09:11:05Z

  • Outcomes: 5 fixed and verified
  • Commits: 27d51ad
Finding Group Outcome phase_9_finding
F008 FG-1 ✓ fixed and verified
F018 FG-2 ✓ fixed and verified
F029 FG-3 ✓ fixed and verified
F033 FG-4 ✓ fixed and verified
F040 FG-5 ✓ fixed and verified

🤖 Generated with Adam's Claude Code Review Command

@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

Auto-recommendation acceptance

rev_01KRCFVJ2J4S9RJ989JXT3FJY5 · run_id=fixrun_01KRDDAGWPXE3XMZNNRSRW3ATQ · choice=apply_all · threshold=60 · reviewer=auto-rec/fabiodalezbackup@gmail.com · ts=2026-05-12T07:09:43Z

2 auto-rec finding(s) eligible at threshold ≥ 60. Of those, 2 auto-promoted via batch, 0 promoted with an edited or alternative hint, 0 skipped per-finding.

Promoted (batch)

  • F031 — The 'Vedi tutti i risultati' link text in the search dropdown is hardcoded Italian and not wrapped in __(), breaking the English/German/French locale support introduced by this same PR.
    • Hint: Replace the hardcoded Italian string 'Vedi tutti i risultati'on line 1938 with a PHP-injected translated value. Following the established pattern in the same file (e.g., lines 2074–2075), declare a JS variable above the search handler:const searchViewAllLabel = ;, then reference it in the concatenated HTML string as searchViewAllLabelin place of the literal. The translation key'Vedi tutti i risultati' must also be added to all locale files (locale/en_US.json, locale/de_DE.json, locale/fr_FR.json) with the appropriate equivalents.
  • F034 — The delete-server button in Z39 server rows is icon-only with no aria-label, making it inaccessible for screen-reader users who cannot determine what the trash icon will delete.
    • Hint: Add an aria-labelattribute to the delete button at line 1122 ofplugins.php. Since the button is generated inside a JS template literal, inject a PHP translated string as a JS variable before the addZ39Serverfunction:const z39DeleteServerLabel = ;, then change the button tag to . This follows the same injection pattern used for other translated labels in the file.

Auto-recommendation acceptance: append-only audit. Each /adamsreview:fix run posts a fresh entry. Promoted findings are now confirmed_mechanical with human_confirmation set; Phase 8 dispatches them next.

Fix groups (committed):
- [FG-1] F007, F041, F012 — storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php: verified
- [FG-2] F031 — app/Views/frontend/layout.php, locale/en_US.json, locale/de_DE.json, locale/fr_FR.json, locale/it_IT.json: verified
- [FG-3] F034 — app/Views/admin/plugins.php: verified

Post-fix review: 3/3 groups verified complete; 0 group(s) partial; 0 group(s) reverted.
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

Walkthrough decisions — 2026-05-12T08:05:17Z

Reviewer: fabiodalezbackup@gmail.com
Scope: Qualifying (5 findings, threshold=50)
Review: rev_01KRCFVJ2J4S9RJ989JXT3FJY5

Finding Action Reason
F008 (deep/correctness, score=55) Promoted Uncached SELECT 1 FROM plugins on every bookDetail() render. Fix: PluginManager::isActive() with static cache.
F018 (deep/correctness, score=55) Promoted CWE-22 prefix-without-separator in Updater path-traversal guard. Fix: append '/' to $realDest comparison + comment.
F029 (light/ux, score=55) Promoted Decorative archive icon in search dropdown missing aria-hidden="true". Inconsistent with 25+ correct usages in codebase.
F033 (light/ux, score=50) Promoted Decorative media-type icon in book-detail badge missing aria-hidden="true".
F040 (deep/security, score=55) Promoted requireAdminForDownload returns 403 instead of 401+WWW-Authenticate. Breaks RFC 7235 — ILS/harvester clients can't discover Basic Auth. Fix: differentiate no-credentials (401) from wrong-credentials (403).

Summary: 5 promoted, 0 skipped. Run /adamsreview:fix to apply all 5 fixes.

🤖 Generated with Adam's Claude Code Review Command

Reconciled fix (one merge pass after Phase 9.pre overlap):
  Findings: F008, F018, F029, F033, F040
  Files:    app/Controllers/FrontendController.php, app/Support/PluginManager.php, app/Support/Updater.php, app/Views/frontend/book-detail.php, app/Views/frontend/layout.php, storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php
  Overlaps: app/Views/frontend/layout.php <- FG-1, FG-3

Findings addressed:
- F008 (PluginManager::isActive cache): replace uncached `SELECT 1 FROM plugins`
  in FrontendController::bookDetail() and frontend/layout.php with per-process
  cached PluginManager::isActive() lookup. Eliminates an extra DB round-trip on
  every catalog page render (including anonymous crawls).
- F018 (CWE-22 prefix-without-separator): tighten path-traversal guard in
  Updater.php — use str_starts_with($parentTarget, $realDest.'/') to prevent
  '/var/www/dest2' from passing when $realDest='/var/www/dest'.
- F029 (WCAG 2.1 SC 1.1.1): add aria-hidden="true" to fa-archive icon in
  search-dropdown builder (frontend/layout.php JS).
- F033 (WCAG 2.1 SC 1.1.1): add aria-hidden="true" to media-type icon in
  book-detail view (label is announced separately).
- F040 (RFC 7235): split requireAdminForDownload() OAI-PMH auth flow —
  401 + WWW-Authenticate when no credentials supplied, 403 when credentials
  present-but-invalid.

Post-fix review: 1/1 group verified complete; 0 partial; 0 reverted.
PR #132 polish bundle:

- book-detail.php: htmlspecialchars() around ucfirst($author['ruolo']) — was the
  only raw role output in the author span (F048).
- tests/pr132-fix-regressions.spec.js: new E2E regression suite covering
  F003 (SRU errorResponse namespace), F018 (UNIMARC 461 $t/$v), F019 (OpenURL
  locale-aware book_url), F020 (ResourceSync 30-day deleted window), F024/F025
  (NCIP UPDATE libri AND deleted_at IS NULL), F027 (ViafAuthority ensureSchema
  return shape), F029 (OAI-PMH ListMetadataFormats includes unimarc), F045
  (NCIP admin endpoints reachable by staff), F048 (book-detail XSS escape).
- tests/discogs-import.spec.js: mock Discogs scrape endpoint for deterministic
  E2E so the suite no longer depends on live Discogs API.
- tests/series-collane-crud.spec.js: mysql invocation now supports TCP host
  alongside socket connection and pipes the migration via stdin.
- tests/bulk-enrich.spec.js: raise per-test timeout to 240s and surface
  Gateway-Timeout retries as explicit errors.
- locale/fr_FR.json: 849 additional Italian→French translations (admin UI,
  archives, NCIP, OAI-PMH, plugin metadata) — closes the FR locale gap left
  by feat/fr-bnf-integration.
- storage/plugins/archives/views/index.php: responsive toolbar (hidden on
  mobile, accessible "more" dropdown), aria-hidden on decorative SVG icons.
@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

Triggering full review on the latest cleanup + fix commits (27d51ad, 9d70038) before running the local adamsreview pipeline. Please cover the 5 findings just resolved (F008 PluginManager::isActive cache, F018 CWE-22 path-traversal guard, F029/F033 WCAG aria-hidden icons, F040 RFC 7235 OAI auth) plus the chore bundle (PR #132 regression tests, FR translations, archives UI responsive).

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

@fabiodalez-dev: Avvio la revisione completa sugli ultimi commit (27d51ad5, 9d70038d). Coprirò in particolare:

  • F008PluginManager::isActive() con cache statica
  • F018 — guard CWE-22 path-traversal (str_starts_with + separatore /)
  • F029/F033aria-hidden="true" sulle icone decorative (WCAG)
  • F040 — risposta 401 + WWW-Authenticate vs 403 (RFC 7235)
  • Bundle chore: test di regressione PR #132, traduzioni fr_FR, UI responsive archives

User feedback: Revisione focalizzata sui 5 fix (F008, F018, F029, F033, F040) e sul bundle chore (regression tests, traduzioni fr_FR, archives UI responsive). Evitare suggerimenti stilistici minori; priorità a bug reali, sicurezza e violazioni delle regole di progetto.

✅ Actions performed

Full review triggered.

@fabiodalez-dev
Copy link
Copy Markdown
Owner Author

Code review

Branch: feat/fr-bnf-integrationmain
PR: #132 (open)
Review ID: rev_01KRDSQ3MA7PCZDZJHCY9ZB7JF
Fix threshold: not yet set (run /adamsreview:fix [threshold] to apply fixes)
Sub-agent tokens: 1,569,013 across 7 invocations

Found 92 findings across all lanes:

  • Deep lane (correctness/security): 50 auto-fixable, 18 uncertain
  • Light lane (ux/policy/architecture): 18 manual, 2 informational, 4 uncertain

Deep lane — correctness & security

✓ Auto-fixable (50)

# Score Impact File Issue
F003 75 correctness app/Controllers/ScrapeController.php:80-87 session_write_close() at byIsbn() entry breaks every in-process caller. LibriController::fetchCover() (line 2351) and ScrapingService::scrapeBookData() (LibraryThingImportController, CsvImportController, BulkEnrichmentService) invoke byIsbn() in-process. After this PR, subsequent $_SESSION[...]=... writes are silently discarded — flash messages, CSRF rotations, success states evaporate.
F004 75 correctness app/Controllers/SearchController.php:233-239 Public unified search merges books+authors+publishers, appends archive results via hook, then array_slice(15). If core results fill 15 (10 books + 5 authors typical), no archive results ever appear — silently dropped. Admin variant caps core to 15 first then appends hooks before 20 cap. Same hook, two different truncation behaviours; public users never see archives in search-as-you-type.
F006 60 correctness app/Support/HtmlHelper.php:241-260 CIDR check only allows IPv4 — IPv6 CIDR ranges in TRUSTED_PROXIES are silently skipped (continue). Operators expecting IPv6 trusted-proxy list to work get no warning and X-Forwarded-Proto will be ignored
F007 60 security app/Support/HtmlHelper.php:332-365 getBaseUrl() trusts X-Forwarded-Proto only when REMOTE_ADDR in TRUSTED_PROXIES (good), but unconditionally trusts HTTP_HOST without checking X-Forwarded-Host against proxy whitelist. Behind a proxy that does NOT rewrite Host, attacker can send crafted Host header poisoning every absolute URL — OAI baseURL, BIBFRAME persistent URI sameAs, FAIR Signposting Link headers, schema.org isPartOf, email links. New OAI/BIBFRAME features amplify blast radius.
F008 60 correctness app/Support/QueryCache.php:97-113 Reworked while loop exits via $staleLock=true continue re-evaluating the while condition. When $timedOut=true with neither $lockAcquired nor $staleLock, code falls through to call $callback() and self::set() WITHOUT holding the flock — defeating stampede protection at the 8-second timeout boundary. Pre-existing latent invariant the refactor did not address.
F010 75 security app/Support/Updater.php:2615-2627 Path traversal: second copyDirectory() still uses vulnerable prefix-match (strpos !== 0) without trailing '/' guard — F018 fix applied only to copyDirectoryRecursive(). A package containing a path that resolves under '/var/www/destSibling' would pass the check when realDest='/var/www/dest'.
F012 60 correctness app/Views/frontend/book-detail.php:180-184 $bookSchema['sameAs'] is now assigned unconditionally — previously only set when $sameAsLinks was non-empty. If the book has no ISBN AND the BIBFRAME plugin is inactive, JSON-LD output will include an empty 'sameAs': [] array.
F015 60 correctness app/Views/frontend/catalog.php:1443-1446 Regex '#^/[A-Za-z0-9/_-.~%]*$#' for archive URL whitelisting rejects query strings (?), fragments (#), and many legitimate URL chars — any archive result URL with a query string or extra punctuation will be silently replaced with '#'
F018 60 correctness bin/pinakes:150-164 cmd_viaf_reconcile_all loads the entire SELECT id, nome FROM autori WHERE viaf_id IS NULL into a PHP array when --limit is unset (defaults to PHP_INT_MAX, SQL LIMIT omitted). For libraries with O(100k+) authors this exhausts PHP memory before any reconciliation work begins; should stream with bounded chunks.
F020 75 correctness installer/database/data_it_IT.sql:340-344 Plain INSERT INTO languages (no ON DUPLICATE KEY UPDATE) in data_it_IT.sql and data_en_US.sql vs data_de_DE.sql and data_fr_FR.sql use ON DUPLICATE KEY UPDATE. Re-running fresh install over existing DB (recovery/reinstall-test.sh) fails with duplicate-key error for it_IT and en_US but succeeds for de_DE/fr_FR. Inconsistent idempotency.
F021 75 correctness installer/database/migrations/migrate_0.7.4.sql:402-500 migrate_0.7.4.sql registers ncip-server, openurl-resolver, bibframe-linked-data, resource-sync into plugins table via INSERT ON DUPLICATE but OMITS oai-pmh-server and viaf-authority — both added to BundledPlugins::LIST in this PR. On 0.7.3→0.7.4 upgrades these will be missing from plugins until autoRegisterBundledPlugins() picks them up on a later request.
F022 60 correctness installer/database/migrations/migrate_0.7.4.sql:678-685 migrate_0.7.4.sql backfills fr_FR using INSERT IGNORE — on installs that already have an fr_FR row with is_active=0 (partial 0.7.4-beta install), activation flag is NOT updated. migrate_0.7.5.sql at line 6-15 uses ON DUPLICATE KEY UPDATE for the same INSERT repairing it. Asymmetric idempotency between two upgrade migrations.
F023 75 correctness installer/database/migrations/migrate_0.7.5.sql:6-15 migrate_0.7.5.sql sets total_keys=4145 with ON DUPLICATE KEY UPDATE while migrate_0.7.4.sql and data_*.sql seeders set 4080. Two different numbers for the same column: upgrades land at 4145; fresh installs at 4080.
F025 75 correctness locale/routes_fr_FR.json:1-35 French route file ships English paths (e.g. /login, /catalog, /register, /events) while routes_it_IT.json uses Italian (/accedi) and routes_de_DE.json uses German equivalents. French users get inconsistent URL voice — French content under English-named URLs breaks the locale-aware routing convention.
F026 75 correctness scripts/create-release.sh:1 violation of CLAUDE.md ABSOLUTE RULE #1: git diff summary shows 'mode change 100755 => 100644 scripts/create-release.sh' — the release script lost its executable bit, so ./scripts/create-release.sh X.Y.Z now fails with Permission denied. Violates CLAUDE.md L5-L12 'ALWAYS use ./scripts/create-release.sh X.Y.Z to create ANY distributable ZIP'.
F027 75 correctness storage/plugins/api-book-scraper/ApiBookScraperPlugin.php:342-347 Inline comment 'Fill empty fields in existing data with API data' is now misleading: the prior '$existing[$key] === ""
F028 75 correctness storage/plugins/archives/ArchivesPlugin.php:327-335 Docblock 'Additive migration for phase 5 image-item columns' is stale — function now also migrates non-image columns iiif_manifest_url, rights_statement_url, ark_identifier, version_note and adds a UNIQUE KEY uq_ark_identifier; the 'image-item' framing no longer matches the body
F029 75 correctness storage/plugins/archives/ArchivesPlugin.php:876-886 Public archive routes registered only for hardcoded ['it_IT','en_US','de_DE'] — fr_FR omitted. After this PR adds French, French installations whose routes_fr_FR.json sets archives to anything other than /archive will never register a route, returning 404. Currently file ships /archive so it accidentally works, but admin customisation to /archives breaks it.
F030 60 security storage/plugins/archives/ArchivesPlugin.php:1411-1421 All redirect Location headers in destroyAction/storeAction/updateAction/authorities controllers wrap url() output with htmlspecialchars(..., ENT_QUOTES, 'UTF-8'). HTTP Location is a URL, not HTML — htmlspecialchars converts & → & in query strings, breaking redirect target. Bug latent because current paths have no ampersands, but moment any caller introduces ?return_to=... or cache busters, redirects break. htmlspecialchars does NOT strip \r\n either, so header injection if a malicious url() return.
F031 75 correctness storage/plugins/archives/ArchivesPlugin.php:1613-1693 handleAssetUpload for kind='document' unlinks the legacy document_path file at lines 1616-1620 BEFORE the INSERT into archival_unit_files; if INSERT fails, post-failure cleanup at line 1685 removes the newly-uploaded file, but the legacy file is already gone — net data loss on first multi-document upload attempt for units with legacy document.
F032 75 correctness storage/plugins/archives/ArchivesPlugin.php:2081-2091 Same Location-header HTML-escaping anti-pattern in authority destroy/attach handlers — htmlspecialchars on a Location header value is semantically wrong (HTTP, not HTML) and corrupts URLs with reserved chars
F033 60 security storage/plugins/archives/ArchivesPlugin.php:5007-5018 findLeafTitle() second query 'SELECT constructed_title, formal_title FROM archival_units WHERE id = ?' lacks AND deleted_at IS NULL filter, violating the project soft-delete consistency rule. Used to label IIIF Range entries — could expose titles of soft-deleted archival units in public manifests.
F034 75 correctness storage/plugins/archives/ArchivesPlugin.php:6424-6432 archiveSearchPattern uses strlen($q) >= 5 and substr($q, 0, -1) which operate on BYTES, not characters; for multibyte UTF-8 input (Italian 'città', French 'éditeur', German 'Übung') strlen counts UTF-8 byte length and substr can truncate inside a multibyte sequence, producing invalid UTF-8 in the LIKE pattern. Should use mb_strlen / mb_substr.
F042 60 correctness storage/plugins/archives/views/public/show.php:150-160 Schema.org JSON-LD emits image and associatedMedia.contentUrl as relative URLs from url($path), but isPartOf.url uses HtmlHelper::getBaseUrl() to build absolute URLs. Schema.org consumers (Google rich results, RDF parsers) require absolute URIs for image/contentUrl — relative URLs from JSON-LD have no base context. Cross-field inconsistency.
F044 60 correctness storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php:298-304 wantsMachineReadable() returns true on Accept: application/json and serializeResponse emits Content-Type application/ld+json. A client requesting plain JSON receives the JSON-LD media type instead — strict negotiators that compare Accept to Content-Type will reject.
F045 75 correctness storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php:325 $viafId is assigned from $book['viaf_id'] but never used after this line in buildGraph — dead code that suggests an intended but missing Work-level VIAF link
F047 60 correctness storage/plugins/ncip-server/NcipServerPlugin.php:258-340 adminPartnersCreateAction stores endpoint_url from admin form without scheme/URL validation; any string (javascript:, file://, whitespace) lands in ncip_partners.endpoint_url and is later used as outbound HTTP target. OaiPmhServerPlugin::digitalAssetAddAction at 2795-2801 properly validates with filter_var + parse_url scheme allowlist. Inconsistent validation across plugin admin endpoints.
F048 60 security storage/plugins/ncip-server/NcipServerPlugin.php:386-395 NCIP request body size cap is 1 MiB but the body is loaded fully into memory via (string)$request->getBody() before being passed to simplexml_load_string with LIBXML_NONET. SimpleXML retains the whole DOM in memory; combined with the unauthenticated /ncip endpoint a single 1 MiB malformed XML can amplify into significantly more RSS, enabling cheap DoS without authentication.
F052 60 correctness storage/plugins/ncip-server/NcipServerPlugin.php:1411-1420 closeLoan UPDATE prestiti requires affected_rows === 1; if loan already 'restituito' WHERE excludes it and affected_rows becomes 0, causing rollback even though no error
F057 60 correctness storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:100-104 OaiPmhServerPlugin::ensureSchema creates mag_project_config with project_code VARCHAR(64) NOT NULL (no default), while migrate_0.7.4.sql line 46 defines the same column with DEFAULT 'PINAKES'. Fresh installs and upgrades end up with structurally different schemas — any INSERT omitting project_code succeeds on upgrade-path systems but fails on fresh installs.
F058 60 security storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:260-313 OAI-PMH advertises deletedRecord:persistent in Identify but only tracks deletions via MySQL BEFORE UPDATE triggers requiring TRIGGER privilege. On shared hosting where CREATE TRIGGER lacks privilege (cPanel/managed MySQL), installTriggers() logs SecureLogger::error but ensureSchema() returns no failed entries, so onActivate() succeeds. Result: OAI repository falsely promises persistent deletion tracking while silently dropping every soft-delete event.
F059 60 security storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:437-440 OAI baseURL announced in Identify response derived from absoluteUrl('/oai'), falls back to $_SERVER['HTTP_HOST'] when APP_CANONICAL_URL not configured. OAI 2.0 spec (§3.1.1) treats this baseURL as stable persistent identifier. Spoofed Host header on first harvest causes harvesters to record evilhost.example.com as canonical baseURL, then return there to refresh — open redirect against harvesters. Amplified by OAI scheme treating $host as persistent namespace component of every record id.
F060 60 correctness storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:730-745 oaiListSets() advertises archives setSpec whenever archival_units TABLE exists, not whenever archives PLUGIN is active. Archives onDeactivate intentionally keeps the table (line 168-174). Result: after Archives deactivated, /oai?verb=ListSets still advertises archives set, /oai?verb=ListRecords&set=archives still returns archival_units rows, and archives plugin hooks are gone. Cross-plugin lifecycle invariant violated.
F063 60 correctness storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2247-2271 saveResumptionToken stores expires_at via PHP date() (server local TZ) while loadResumptionToken compares against MySQL NOW() (session TZ); when PHP and MySQL timezones differ, the 24h TTL is silently shifted — harvesters can get spurious badResumptionToken errors or stale tokens valid hours after they should have expired.
F065 60 correctness storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2693-2694 Leader is built as sprintf('%05d', $recordLength) . 'nam 22' . sprintf('%05d', $baseAddr) . ' 4500' — MARC21 leader byte 22 (base address indicator position 12-16) requires zero-padded digits; ' 4500' uses three spaces then 4500, but MARC21 mandates '4500' at positions 20-23 immediately after entry map; the three leading spaces at positions 17-19 should be indicator counts
F067 75 correctness storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2834 bind_param type string 'issssiii' (4 's', 4 'i') doesn't match arguments: $bookId(i), $url(s), $filetype(s), $md5(s), $filesize(i), $width(i), $height(i), $ppi(i) — $filesize at position 5 is bound as string but is an int; should be 'isssiiii'
F068 60 correctness storage/plugins/oai-pmh-server/views/book-digital-assets.php:14-20 Top-level function oaiFormatBytes() is declared in a view file included via book.form.fields hook — if the hook ever fires more than once per request (e.g. multiple books on a single admin page or via PHPUnit reuse), PHP will fatal with 'Cannot redeclare function'
F072 75 correctness storage/plugins/open-library/OpenLibraryPlugin.php:335-342 Merge loop semantics drifted from surrounding 'fill empty fields' intent — removed '
F077 60 security storage/plugins/resource-sync/ResourceSyncPlugin.php:120-145 All four ResourceSync endpoints (/.well-known/resourcesync, capability/resource/change lists) are registered as public — no auth middleware or admin gate. By design for ResourceSync spec, but publishes IDs, lastmod, and tombstones to any unauthenticated visitor. Flag for review.
F078 60 security storage/plugins/resource-sync/ResourceSyncPlugin.php:486-500 Non-since ResourceSync path filters WHERE (deleted_at IS NULL OR deleted_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)). After 30 days, soft-deleted books permanently disappear from resourcedump/changelist. Harvesters whose last poll exceeded the window never learn book was deleted — keep stale records indefinitely. ResourceSync semantics require persistent tombstones or clear baseline-rebuild flag.
F079 60 security storage/plugins/viaf-authority/ViafAuthorityPlugin.php:370-381 VIAF AutoSuggest curl sets CURLOPT_PROTOCOLS to restrict HTTP/HTTPS but does NOT set CURLOPT_REDIR_PROTOCOLS while enabling FOLLOWLOCATION+MAXREDIRS=3. In older libcurl (<7.85) default redir-protocols includes file://, gopher://, scp:// — a redirect chain from viaf.org can in principle land on file:// URL. SruClient.php:217-218 correctly sets BOTH.
F080 60 security storage/plugins/viaf-authority/ViafAuthorityPlugin.php:506-514 JSONP support in /admin/api/reconcile (?callback=...): even though callback is sanitized to [A-Za-z0-9_$], the JSON payload includes data from local autori (names, viaf_id, isni). An admin lured to a malicious page could leak reconcile results cross-origin via a <script src='?callback=...'> tag, bypassing same-origin protections that the JSON Content-Type normally enforces.
F081 75 correctness storage/plugins/viaf-authority/ViafAuthorityPlugin.php:559-570 setAuthorIsniAction unconditionally rewrites authority_source/authority_confidence to 'isni'/'exact' (or NULL/NULL when ISNI cleared), clobbering any pre-existing VIAF authority marker on the same author row. setAuthorAuthorityAction at lines 499-508 preserves both VIAF and ISNI cleanly; the two writer paths are asymmetric.
F082 60 security storage/plugins/viaf-authority/ViafAuthorityPlugin.php:860-895 validateCsrf() is bypassed entirely when the request carries Authorization: Basic ..., allowing write endpoints (setAuthorViafAction, setAuthorIsniAction) to mutate state via simple HTTP Basic without CSRF. Combined with permissive authenticateBasic() that accepts admin/staff credentials, an XHR-based or curl-based CSRF can succeed if an attacker captures Basic credentials or reuses cached browser credentials.
F083 75 correctness storage/plugins/z39-server/Z39ServerPlugin.php:754-768 Doc comment 'Fallback: simple merge for empty fields only' no longer reflects code — the merge predicate lost the '
F084 75 correctness storage/plugins/z39-server/Z39ServerPlugin.php:893-903 decryptSetting reads only getenv() while PluginManager::getEncryptionKey() reads $_ENV first then getenv; phpdotenv createImmutable populates $_ENV but NOT getenv by default, so PluginManager can write encrypted settings but Z39ServerPlugin cannot decrypt them. The comment 'same order as PluginManager' is incorrect.
F086 60 correctness storage/plugins/z39-server/classes/SRUServer.php:1075-1078 Diagnostic ,
Details, elements are now in NS_SRU (http://www.loc.gov/zing/srw/) — per SRU spec these should be in the diagnostic namespace (info:srw/diagnostic/1/, declared as NS_DIAG). createElementNS($ns, 'uri', self::NS_DIAG . $code) wraps the diagnostic in the wrong namespace
F088 60 correctness storage/plugins/z39-server/classes/SruClient.php:83-92 Static $lastRequest rate limiter is per-PHP-process. Each PHP-FPM worker has its own copy, so on multi-worker box the actual outbound rate to BNF/SUDOC is N×1 req/s rather than documented 1 req/s. With prefork Apache+mod_php (used in production per CLAUDE.md), becomes one-rate-per-Apache-child. BNF historically returns 429 on bursts. Limiter creates false sense of safety in 'max 1 request/second per server (static across instances)'.
F091 60 correctness storage/plugins/z39-server/classes/SruClient.php:564-573 parseMarcxchangeXml fallback //*[local-name()='record'] first matches the outer SRU envelope element (which wraps recordData/recordSchema/recordPacking) before the inner MARCXchange data record; when MARCXchange namespace lookup fails, ->item(0) returns the SRU wrapper, then subsequent .//datafield descends through unrelated SRU metadata. For multi-record responses this conflates fields across records.
F092 60 correctness storage/plugins/z39-server/classes/UNIMARCXMLFormatter.php:24-25 Record element is created in MARC21 namespace (http://www.loc.gov/MARC21/slim) but labeled type='Bibliographic' (UNIMARC concept) and described as 'UNIMARC/MARCXchange'; clients dispatching on namespace will treat this as MARC21 data even though field codes are UNIMARC (200/210/700, not 245/260/100)
Details and fix proposals

F003 — session_write_close() at byIsbn() entry breaks every in-process caller. LibriController::fetchCover() (line 2351) and ScrapingService::scrapeBookData() (LibraryThingImportController, CsvImportController, BulkEnrichmentService) invoke byIsbn() in-process. After this PR, subsequent $_SESSION[...]=... writes are silently discarded — flash messages, CSRF rotations, success states evaporate.

File: app/Controllers/ScrapeController.php:80-87
Score: 75 (strong)
Reason: internal-7-lens consensus, high confidence; full Phase 4 validation skipped (sonnet quota constraint)

F004 — Public unified search merges books+authors+publishers, appends archive results via hook, then array_slice(15). If core results fill 15 (10 books + 5 authors typical), no archive results ever appear — silently dropped. Admin variant caps core to 15 first then appends hooks before 20 cap. Same hook, two different truncation behaviours; public users never see archives in search-as-you-type.

File: app/Controllers/SearchController.php:233-239
Score: 75 (strong)
Reason: internal-7-lens consensus, high confidence; full Phase 4 validation skipped (sonnet quota constraint)

F006 — CIDR check only allows IPv4 — IPv6 CIDR ranges in TRUSTED_PROXIES are silently skipped (continue). Operators expecting IPv6 trusted-proxy list to work get no warning and X-Forwarded-Proto will be ignored || isRemoteAddrTrustedProxy silently skips IPv6 CIDR entries via strlen($networkIp) !== 4 (line 250) with no warning logged — admins configuring TRUSTED_PROXIES with IPv6 CIDR (e.g. 2001:db8::/32) get no error, but the proxy is not trusted and X-Forwarded-Proto detection silently breaks on dual-stack deployments.

File: app/Support/HtmlHelper.php:241-260
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F007 — getBaseUrl() trusts X-Forwarded-Proto only when REMOTE_ADDR in TRUSTED_PROXIES (good), but unconditionally trusts HTTP_HOST without checking X-Forwarded-Host against proxy whitelist. Behind a proxy that does NOT rewrite Host, attacker can send crafted Host header poisoning every absolute URL — OAI baseURL, BIBFRAME persistent URI sameAs, FAIR Signposting Link headers, schema.org isPartOf, email links. New OAI/BIBFRAME features amplify blast radius.

File: app/Support/HtmlHelper.php:332-365
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F008 — Reworked while loop exits via $staleLock=true continue re-evaluating the while condition. When $timedOut=true with neither $lockAcquired nor $staleLock, code falls through to call $callback() and self::set() WITHOUT holding the flock — defeating stampede protection at the 8-second timeout boundary. Pre-existing latent invariant the refactor did not address.

File: app/Support/QueryCache.php:97-113
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F010 — Path traversal: second copyDirectory() still uses vulnerable prefix-match (strpos !== 0) without trailing '/' guard — F018 fix applied only to copyDirectoryRecursive(). A package containing a path that resolves under '/var/www/destSibling' would pass the check when realDest='/var/www/dest'. || copyDirectory() retains the old vulnerable strpos($parentTarget, $realDest) !== 0 prefix check while parallel copyDirectoryRecursive() at line 2247 was hardened with !== $realDest && !str_starts_with($realDest . '/'); the path-traversal/prefix-collision strengthening is asymmetric across the two parallel directory-copy paths. || copyDirectory() retains the prefix-collision security bug fixed in sibling copyDirectoryRecursive() during the same edit. Recursive variant uses !== $realDest && !str_starts_with($realDest.'/'); copyDirectory still uses original strpos !== 0. Two parallel paths whose security invariants diverged after one was patched.

File: app/Support/Updater.php:2615-2627
Score: 75 (strong)
Reason: internal-7-lens consensus, high confidence; full Phase 4 validation skipped (sonnet quota constraint)

F012 — $bookSchema['sameAs'] is now assigned unconditionally — previously only set when $sameAsLinks was non-empty. If the book has no ISBN AND the BIBFRAME plugin is inactive, JSON-LD output will include an empty 'sameAs': [] array.

File: app/Views/frontend/book-detail.php:180-184
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F015 — Regex '#^/[A-Za-z0-9/_-.~%]*$#' for archive URL whitelisting rejects query strings (?), fragments (#), and many legitimate URL chars — any archive result URL with a query string or extra punctuation will be silently replaced with '#'

File: app/Views/frontend/catalog.php:1443-1446
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F018 — cmd_viaf_reconcile_all loads the entire SELECT id, nome FROM autori WHERE viaf_id IS NULL into a PHP array when --limit is unset (defaults to PHP_INT_MAX, SQL LIMIT omitted). For libraries with O(100k+) authors this exhausts PHP memory before any reconciliation work begins; should stream with bounded chunks.

File: bin/pinakes:150-164
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F020 — Plain INSERT INTO languages (no ON DUPLICATE KEY UPDATE) in data_it_IT.sql and data_en_US.sql vs data_de_DE.sql and data_fr_FR.sql use ON DUPLICATE KEY UPDATE. Re-running fresh install over existing DB (recovery/reinstall-test.sh) fails with duplicate-key error for it_IT and en_US but succeeds for de_DE/fr_FR. Inconsistent idempotency. || languages seeder rows ship stale, wrong total_keys/translated_keys/completion_percentage that are surfaced on admin Languages UI as honest data. Real fr_FR.json has 4993 keys, seeder claims 4080; it_IT.json has 493 keys, seeder claims 2015; en_US.json/de_DE.json have 4653, seeder claims 2015/4009. All four counters lie.

File: installer/database/data_it_IT.sql:340-344
Score: 75 (strong)
Reason: internal-7-lens consensus, high confidence; full Phase 4 validation skipped (sonnet quota constraint)

F021 — migrate_0.7.4.sql registers ncip-server, openurl-resolver, bibframe-linked-data, resource-sync into plugins table via INSERT ON DUPLICATE but OMITS oai-pmh-server and viaf-authority — both added to BundledPlugins::LIST in this PR. On 0.7.3→0.7.4 upgrades these will be missing from plugins until autoRegisterBundledPlugins() picks them up on a later request.

File: installer/database/migrations/migrate_0.7.4.sql:402-500
Score: 75 (strong)
Reason: internal-7-lens consensus, high confidence; full Phase 4 validation skipped (sonnet quota constraint)

F022 — migrate_0.7.4.sql backfills fr_FR using INSERT IGNORE — on installs that already have an fr_FR row with is_active=0 (partial 0.7.4-beta install), activation flag is NOT updated. migrate_0.7.5.sql at line 6-15 uses ON DUPLICATE KEY UPDATE for the same INSERT repairing it. Asymmetric idempotency between two upgrade migrations.

File: installer/database/migrations/migrate_0.7.4.sql:678-685
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F023 — migrate_0.7.5.sql sets total_keys=4145 with ON DUPLICATE KEY UPDATE while migrate_0.7.4.sql and data_*.sql seeders set 4080. Two different numbers for the same column: upgrades land at 4145; fresh installs at 4080.

File: installer/database/migrations/migrate_0.7.5.sql:6-15
Score: 75 (strong)
Reason: internal-7-lens consensus, high confidence; full Phase 4 validation skipped (sonnet quota constraint)

F025 — French route file ships English paths (e.g. /login, /catalog, /register, /events) while routes_it_IT.json uses Italian (/accedi) and routes_de_DE.json uses German equivalents. French users get inconsistent URL voice — French content under English-named URLs breaks the locale-aware routing convention. || French routes file is byte-identical to routes_en_US.json — all paths are English (/login, /catalog, /book, /author, /publisher, /about-us, /privacy-policy) instead of French (/connexion, /catalogue, /livre, /auteur). it_IT and de_DE use translated paths. UX regression: French users get English URLs. || routes_fr_FR.json is a byte-identical clone of routes_en_US.json — keys like login/profile/catalog/book resolve to English URLs (/login, /profile, /catalog, /book) for French installs. Other locales properly translate (/accedi IT, /anmelden DE). French users see English URLs throughout — route translation contract is broken for the new locale introduced by this same PR.

File: locale/routes_fr_FR.json:1-35
Score: 75 (strong)
Reason: internal-7-lens consensus, high confidence; full Phase 4 validation skipped (sonnet quota constraint)

F026 — violation of CLAUDE.md ABSOLUTE RULE #1: git diff summary shows 'mode change 100755 => 100644 scripts/create-release.sh' — the release script lost its executable bit, so ./scripts/create-release.sh X.Y.Z now fails with Permission denied. Violates CLAUDE.md L5-L12 'ALWAYS use ./scripts/create-release.sh X.Y.Z to create ANY distributable ZIP'.

File: scripts/create-release.sh:1
Score: 75 (strong)
Reason: internal-7-lens consensus, high confidence; full Phase 4 validation skipped (sonnet quota constraint)

F027 — Inline comment 'Fill empty fields in existing data with API data' is now misleading: the prior '$existing[$key] === "" || $existing[$key] === null' was reduced to '$existing[$key] === ""', so null-valued existing fields are no longer overwritten by API data despite the comment implying all empty fields are filled

File: storage/plugins/api-book-scraper/ApiBookScraperPlugin.php:342-347
Score: 75 (strong)
Reason: internal-7-lens consensus, high confidence; full Phase 4 validation skipped (sonnet quota constraint)

F028 — Docblock 'Additive migration for phase 5 image-item columns' is stale — function now also migrates non-image columns iiif_manifest_url, rights_statement_url, ark_identifier, version_note and adds a UNIQUE KEY uq_ark_identifier; the 'image-item' framing no longer matches the body || Docblock asserts migrateImageColumns is 'Idempotent; safe to call on every activation' but body was refactored to throw RuntimeException on any ALTER failure; the implication of 'safe' (silent retry-friendly) no longer holds — a single ALTER error now aborts activation

File: storage/plugins/archives/ArchivesPlugin.php:327-335
Score: 75 (strong)
Reason: internal-7-lens consensus, high confidence; full Phase 4 validation skipped (sonnet quota constraint)

F029 — Public archive routes registered only for hardcoded ['it_IT','en_US','de_DE'] — fr_FR omitted. After this PR adds French, French installations whose routes_fr_FR.json sets archives to anything other than /archive will never register a route, returning 404. Currently file ships /archive so it accidentally works, but admin customisation to /archives breaks it.

File: storage/plugins/archives/ArchivesPlugin.php:876-886
Score: 75 (strong)
Reason: internal-7-lens consensus, high confidence; full Phase 4 validation skipped (sonnet quota constraint)

F030 — All redirect Location headers in destroyAction/storeAction/updateAction/authorities controllers wrap url() output with htmlspecialchars(..., ENT_QUOTES, 'UTF-8'). HTTP Location is a URL, not HTML — htmlspecialchars converts & → & in query strings, breaking redirect target. Bug latent because current paths have no ampersands, but moment any caller introduces ?return_to=... or cache busters, redirects break. htmlspecialchars does NOT strip \r\n either, so header injection if a malicious url() return. || Location HTTP header is HTML-escaped via htmlspecialchars(..., ENT_QUOTES) — turns &/</>/'/" into HTML entities in the redirect target, which is not URL encoding and will produce broken redirects

File: storage/plugins/archives/ArchivesPlugin.php:1411-1421
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F031 — handleAssetUpload for kind='document' unlinks the legacy document_path file at lines 1616-1620 BEFORE the INSERT into archival_unit_files; if INSERT fails, post-failure cleanup at line 1685 removes the newly-uploaded file, but the legacy file is already gone — net data loss on first multi-document upload attempt for units with legacy document.

File: storage/plugins/archives/ArchivesPlugin.php:1613-1693
Score: 75 (strong)
Reason: internal-7-lens consensus, high confidence; full Phase 4 validation skipped (sonnet quota constraint)

F032 — Same Location-header HTML-escaping anti-pattern in authority destroy/attach handlers — htmlspecialchars on a Location header value is semantically wrong (HTTP, not HTML) and corrupts URLs with reserved chars

File: storage/plugins/archives/ArchivesPlugin.php:2081-2091
Score: 75 (strong)
Reason: internal-7-lens consensus, high confidence; full Phase 4 validation skipped (sonnet quota constraint)

F033 — findLeafTitle() second query 'SELECT constructed_title, formal_title FROM archival_units WHERE id = ?' lacks AND deleted_at IS NULL filter, violating the project soft-delete consistency rule. Used to label IIIF Range entries — could expose titles of soft-deleted archival units in public manifests.

File: storage/plugins/archives/ArchivesPlugin.php:5007-5018
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F034 — archiveSearchPattern uses strlen($q) >= 5 and substr($q, 0, -1) which operate on BYTES, not characters; for multibyte UTF-8 input (Italian 'città', French 'éditeur', German 'Übung') strlen counts UTF-8 byte length and substr can truncate inside a multibyte sequence, producing invalid UTF-8 in the LIKE pattern. Should use mb_strlen / mb_substr.

File: storage/plugins/archives/ArchivesPlugin.php:6424-6432
Score: 75 (strong)
Reason: internal-7-lens consensus, high confidence; full Phase 4 validation skipped (sonnet quota constraint)

F042 — Schema.org JSON-LD emits image and associatedMedia.contentUrl as relative URLs from url($path), but isPartOf.url uses HtmlHelper::getBaseUrl() to build absolute URLs. Schema.org consumers (Google rich results, RDF parsers) require absolute URIs for image/contentUrl — relative URLs from JSON-LD have no base context. Cross-field inconsistency.

File: storage/plugins/archives/views/public/show.php:150-160
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F044 — wantsMachineReadable() returns true on Accept: application/json and serializeResponse emits Content-Type application/ld+json. A client requesting plain JSON receives the JSON-LD media type instead — strict negotiators that compare Accept to Content-Type will reject.

File: storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php:298-304
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F045 — $viafId is assigned from $book['viaf_id'] but never used after this line in buildGraph — dead code that suggests an intended but missing Work-level VIAF link

File: storage/plugins/bibframe-linked-data/BibframeLinkedDataPlugin.php:325
Score: 75 (strong)
Reason: internal-7-lens consensus, high confidence; full Phase 4 validation skipped (sonnet quota constraint)

F047 — adminPartnersCreateAction stores endpoint_url from admin form without scheme/URL validation; any string (javascript:, file://, whitespace) lands in ncip_partners.endpoint_url and is later used as outbound HTTP target. OaiPmhServerPlugin::digitalAssetAddAction at 2795-2801 properly validates with filter_var + parse_url scheme allowlist. Inconsistent validation across plugin admin endpoints. || adminPartnersListAction and adminTransactionsListAction redirect unauthenticated callers to hardcoded url('/admin/login'), but no /admin/login route is registered and login is locale-translated (/accedi IT, /login EN, /anmelden DE) — the 302 lands on a 404. Violates CLAUDE.md rule #4 (no hardcoded routes). || adminPartnersListAction is invoked from POST handlers (create/delete) without re-issuing a redirect — POST-Redirect-GET broken: a browser refresh re-submits the form

File: storage/plugins/ncip-server/NcipServerPlugin.php:258-340
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F048 — NCIP request body size cap is 1 MiB but the body is loaded fully into memory via (string)$request->getBody() before being passed to simplexml_load_string with LIBXML_NONET. SimpleXML retains the whole DOM in memory; combined with the unauthenticated /ncip endpoint a single 1 MiB malformed XML can amplify into significantly more RSS, enabling cheap DoS without authentication. || Public unauthenticated NCIP LookupItem (POST /ncip without Basic Auth) returns book id, title, author, publication date and availability counts (copie_totali/copie_disponibili). Provides an unauthenticated read API for the entire catalog over XML/NCIP without rate limiting visible in the diff.

File: storage/plugins/ncip-server/NcipServerPlugin.php:386-395
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F052 — closeLoan UPDATE prestiti requires affected_rows === 1; if loan already 'restituito' WHERE excludes it and affected_rows becomes 0, causing rollback even though no error

File: storage/plugins/ncip-server/NcipServerPlugin.php:1411-1420
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F057 — OaiPmhServerPlugin::ensureSchema creates mag_project_config with project_code VARCHAR(64) NOT NULL (no default), while migrate_0.7.4.sql line 46 defines the same column with DEFAULT 'PINAKES'. Fresh installs and upgrades end up with structurally different schemas — any INSERT omitting project_code succeeds on upgrade-path systems but fails on fresh installs. || onUninstall() is empty {} — leaves MySQL triggers trg_libri_soft_delete and trg_archival_soft_delete in place after plugin uninstall, continuing to write into the dropped/lingering oai_deleted_records table. Every soft delete fires an orphan trigger. onUninstall() should DROP TRIGGER IF EXISTS for both triggers.

File: storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:100-104
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F058 — OAI-PMH advertises deletedRecord:persistent in Identify but only tracks deletions via MySQL BEFORE UPDATE triggers requiring TRIGGER privilege. On shared hosting where CREATE TRIGGER lacks privilege (cPanel/managed MySQL), installTriggers() logs SecureLogger::error but ensureSchema() returns no failed entries, so onActivate() succeeds. Result: OAI repository falsely promises persistent deletion tracking while silently dropping every soft-delete event. || Public unauthenticated OAI-PMH endpoint (GET/POST /oai) exposes book/archival_unit metadata including titles, authors, publisher, ISBN, descriptions, plus persistent deleted-record tombstones (entity ids + datestamps) for soft-deleted records via the trigger-fed oai_deleted_records table. By design per OAI-PMH spec, but flag because it adds a broad public data surface. || Triggers hardcode 'oai:pinakes:book:N' / 'oai:pinakes:archival_unit:N' literals in oai_deleted_records.oai_id. Runtime buildOaiId() (line 2393) ignores stored oai_id and builds 'oai:{host}:book:N' from request host. Deleted-record OAI ids shown to harvesters are host-derived (and Host-header-poisonable) but DB stores 'pinakes' — undocumented dual representation. || installTriggers() interpolates $def['body'] verbatim into CREATE TRIGGER DDL. The body itself is a static dev-controlled string but the BEFORE UPDATE trigger writes oai_deleted_records on every libri/archival_units soft-delete using NOW() — exfiltrates deleted record existence + timestamps via the

File: storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:260-313
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F059 — OAI baseURL announced in Identify response derived from absoluteUrl('/oai'), falls back to $_SERVER['HTTP_HOST'] when APP_CANONICAL_URL not configured. OAI 2.0 spec (§3.1.1) treats this baseURL as stable persistent identifier. Spoofed Host header on first harvest causes harvesters to record evilhost.example.com as canonical baseURL, then return there to refresh — open redirect against harvesters. Amplified by OAI scheme treating $host as persistent namespace component of every record id. || oaiPmhAction probabilistically purges resumption tokens using random_int(0,99)===0. Since tokens table is unauthenticated-write (any List request that returns a next page persists a token row), an attacker can spam ListRecords requests to bloat oai_resumption_tokens (DoS via storage exhaustion) before the 1% cleanup chance fires.

File: storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:437-440
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F060 — oaiListSets() advertises archives setSpec whenever archival_units TABLE exists, not whenever archives PLUGIN is active. Archives onDeactivate intentionally keeps the table (line 168-174). Result: after Archives deactivated, /oai?verb=ListSets still advertises archives set, /oai?verb=ListRecords&set=archives still returns archival_units rows, and archives plugin hooks are gone. Cross-plugin lifecycle invariant violated.

File: storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:730-745
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F063 — saveResumptionToken stores expires_at via PHP date() (server local TZ) while loadResumptionToken compares against MySQL NOW() (session TZ); when PHP and MySQL timezones differ, the 24h TTL is silently shifted — harvesters can get spurious badResumptionToken errors or stale tokens valid hours after they should have expired.

File: storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2247-2271
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F065 — Leader is built as sprintf('%05d', $recordLength) . 'nam 22' . sprintf('%05d', $baseAddr) . ' 4500' — MARC21 leader byte 22 (base address indicator position 12-16) requires zero-padded digits; ' 4500' uses three spaces then 4500, but MARC21 mandates '4500' at positions 20-23 immediately after entry map; the three leading spaces at positions 17-19 should be indicator counts

File: storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2693-2694
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F067 — bind_param type string 'issssiii' (4 's', 4 'i') doesn't match arguments: $bookId(i), $url(s), $filetype(s), $md5(s), $filesize(i), $width(i), $height(i), $ppi(i) — $filesize at position 5 is bound as string but is an int; should be 'isssiiii'

File: storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2834
Score: 75 (strong)
Reason: internal-7-lens consensus, high confidence; full Phase 4 validation skipped (sonnet quota constraint)

F068 — Top-level function oaiFormatBytes() is declared in a view file included via book.form.fields hook — if the hook ever fires more than once per request (e.g. multiple books on a single admin page or via PHPUnit reuse), PHP will fatal with 'Cannot redeclare function'

File: storage/plugins/oai-pmh-server/views/book-digital-assets.php:14-20
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F072 — Merge loop semantics drifted from surrounding 'fill empty fields' intent — removed '|| $existing[$key] === null' branch means null-valued existing fields are no longer overwritten by Open Library API data even though they would have been before this PR

File: storage/plugins/open-library/OpenLibraryPlugin.php:335-342
Score: 75 (strong)
Reason: internal-7-lens consensus, high confidence; full Phase 4 validation skipped (sonnet quota constraint)

F077 — All four ResourceSync endpoints (/.well-known/resourcesync, capability/resource/change lists) are registered as public — no auth middleware or admin gate. By design for ResourceSync spec, but publishes IDs, lastmod, and tombstones to any unauthenticated visitor. Flag for review.

File: storage/plugins/resource-sync/ResourceSyncPlugin.php:120-145
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F078 — Non-since ResourceSync path filters WHERE (deleted_at IS NULL OR deleted_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)). After 30 days, soft-deleted books permanently disappear from resourcedump/changelist. Harvesters whose last poll exceeded the window never learn book was deleted — keep stale records indefinitely. ResourceSync semantics require persistent tombstones or clear baseline-rebuild flag. || Public endpoint /resync/changelist.xml with ?from=... query branch fetches rows where updated_at>=? OR deleted_at>=? without any deleted_at IS NULL guard — exposes IDs and timestamps of soft-deleted books to anonymous harvesters, no 30-day tombstone limit applied (unlike the since=null branch).

File: storage/plugins/resource-sync/ResourceSyncPlugin.php:486-500
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F079 — VIAF AutoSuggest curl sets CURLOPT_PROTOCOLS to restrict HTTP/HTTPS but does NOT set CURLOPT_REDIR_PROTOCOLS while enabling FOLLOWLOCATION+MAXREDIRS=3. In older libcurl (<7.85) default redir-protocols includes file://, gopher://, scp:// — a redirect chain from viaf.org can in principle land on file:// URL. SruClient.php:217-218 correctly sets BOTH.

File: storage/plugins/viaf-authority/ViafAuthorityPlugin.php:370-381
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F080 — JSONP support in /admin/api/reconcile (?callback=...): even though callback is sanitized to [A-Za-z0-9_$], the JSON payload includes data from local autori (names, viaf_id, isni). An admin lured to a malicious page could leak reconcile results cross-origin via a <script src='?callback=...'> tag, bypassing same-origin protections that the JSON Content-Type normally enforces. || reconcileSearch builds LIKE pattern escaping % and _, then binds with ESCAPE '\' — the ESCAPE clause specifies '\' (literal backslash after PHP and SQL unescaping), but the PHP escape replacement substitutes single backslash, leaving quoting mismatch

File: storage/plugins/viaf-authority/ViafAuthorityPlugin.php:506-514
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F081 — setAuthorIsniAction unconditionally rewrites authority_source/authority_confidence to 'isni'/'exact' (or NULL/NULL when ISNI cleared), clobbering any pre-existing VIAF authority marker on the same author row. setAuthorAuthorityAction at lines 499-508 preserves both VIAF and ISNI cleanly; the two writer paths are asymmetric.

File: storage/plugins/viaf-authority/ViafAuthorityPlugin.php:559-570
Score: 75 (strong)
Reason: internal-7-lens consensus, high confidence; full Phase 4 validation skipped (sonnet quota constraint)

F082 — validateCsrf() is bypassed entirely when the request carries Authorization: Basic ..., allowing write endpoints (setAuthorViafAction, setAuthorIsniAction) to mutate state via simple HTTP Basic without CSRF. Combined with permissive authenticateBasic() that accepts admin/staff credentials, an XHR-based or curl-based CSRF can succeed if an attacker captures Basic credentials or reuses cached browser credentials.

File: storage/plugins/viaf-authority/ViafAuthorityPlugin.php:860-895
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F083 — Doc comment 'Fallback: simple merge for empty fields only' no longer reflects code — the merge predicate lost the '|| $existing[$key] === null' branch, so null-valued fields are now retained rather than being filled with the new value

File: storage/plugins/z39-server/Z39ServerPlugin.php:754-768
Score: 75 (strong)
Reason: internal-7-lens consensus, high confidence; full Phase 4 validation skipped (sonnet quota constraint)

F084 — decryptSetting reads only getenv() while PluginManager::getEncryptionKey() reads $_ENV first then getenv; phpdotenv createImmutable populates $_ENV but NOT getenv by default, so PluginManager can write encrypted settings but Z39ServerPlugin cannot decrypt them. The comment 'same order as PluginManager' is incorrect.

File: storage/plugins/z39-server/Z39ServerPlugin.php:893-903
Score: 75 (strong)
Reason: internal-7-lens consensus, high confidence; full Phase 4 validation skipped (sonnet quota constraint)

F086 — Diagnostic ,
Details, elements are now in NS_SRU (http://www.loc.gov/zing/srw/) — per SRU spec these should be in the diagnostic namespace (info:srw/diagnostic/1/, declared as NS_DIAG). createElementNS($ns, 'uri', self::NS_DIAG . $code) wraps the diagnostic in the wrong namespace

File: storage/plugins/z39-server/classes/SRUServer.php:1075-1078
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F088 — Static $lastRequest rate limiter is per-PHP-process. Each PHP-FPM worker has its own copy, so on multi-worker box the actual outbound rate to BNF/SUDOC is N×1 req/s rather than documented 1 req/s. With prefork Apache+mod_php (used in production per CLAUDE.md), becomes one-rate-per-Apache-child. BNF historically returns 429 on bursts. Limiter creates false sense of safety in 'max 1 request/second per server (static across instances)'.

File: storage/plugins/z39-server/classes/SruClient.php:83-92
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F091 — parseMarcxchangeXml fallback //*[local-name()='record'] first matches the outer SRU envelope element (which wraps recordData/recordSchema/recordPacking) before the inner MARCXchange data record; when MARCXchange namespace lookup fails, ->item(0) returns the SRU wrapper, then subsequent .//datafield descends through unrelated SRU metadata. For multi-record responses this conflates fields across records.

File: storage/plugins/z39-server/classes/SruClient.php:564-573
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

F092 — Record element is created in MARC21 namespace (http://www.loc.gov/MARC21/slim) but labeled type='Bibliographic' (UNIMARC concept) and described as 'UNIMARC/MARCXchange'; clients dispatching on namespace will treat this as MARC21 data even though field codes are UNIMARC (200/210/700, not 245/260/100)

File: storage/plugins/z39-server/classes/UNIMARCXMLFormatter.php:24-25
Score: 60 (moderate)
Reason: internal-7-lens consensus, medium confidence; full Phase 4 validation skipped (sonnet quota constraint)

ℹ Uncertain (18)

# Score Impact File Issue
F002 45 security app/Controllers/FrontendController.php:760-790 Book-detail response Link header injects author VIAF URI from autori.viaf_uri DB column. Although filter_var + scheme regex + strpbrk for <>,\r\n are applied, the underlying VIAF URI value is admin-controlled and could carry CRLF or '>'-terminator characters that the strpbrk filter checks for — but inet protections rely on those checks being complete. An admin storing 'https://viaf.org/viaf/123\"; rel=stylesheet; href=evil' could try to break out via a quote character (not in the deny list).
F009 45 correctness app/Support/Updater.php:2244-2254 After str_starts_with check using $realDest with forward-slashes, the prefix-collision guard appends '/' — but when $realDest is the filesystem root '/' on Unix, $realDest . '/' becomes '//' and str_starts_with(...,'//') will reject every legitimate path
F011 45 correctness app/Views/admin/plugins.php:1004-1008 presetConfig copies `preset.quote_search_terms
F016 45 correctness app/Views/frontend/layout.php:1731-1738 searchViewAllLabel computed once via PHP json_encode(__('Vedi tutti i risultati'), JSON_HEX_TAG) at script render time, but the same script body uses __('Archivio'), __('Libri'), __('Autori'), __('Editori'), __(' libri') as if PHP — but they're inside JS function body (after first <script>), meaning they call a JS function named __. JS __() defined for book_form.php (line 1077) but frontend layout has no equivalent JS __ init — anonymous frontend pages may hit ReferenceError when search returns results.
F049 45 security storage/plugins/ncip-server/NcipServerPlugin.php:505-525 NCIP LookupUserResponse for a patron looking up themselves emits AgencyUserPrivilegeType containing tipo_utente (admin/staff/utente) of the requester. Exposes the user's privilege level back to a Basic-Auth'd client over the network — minor info disclosure useful for reconnaissance.
F050 45 correctness storage/plugins/ncip-server/NcipServerPlugin.php:1177-1230 createLoanAtomic() calls $this->db->begin_transaction()/commit()/rollback() without the @@autocommit detection pattern documented in CLAUDE.md and used in recalculateBookAvailability. If an external orchestrator wraps an NCIP request in an outer transaction, inner begin_transaction implicitly commits the outer one corrupting audit log.
F051 45 correctness storage/plugins/ncip-server/NcipServerPlugin.php:1241 createLoanNcip bind_param('iisss', ...) — if origine ENUM doesn't include 'ncip', the INSERT silently fails inserting empty string in strict mode and returns null loan id with no actionable error logged
F061 45 correctness storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:1101-1102 dc:type emits ucfirst($tipo) — for non-ASCII media types with UTF-8 characters this only uppercases the first byte and may corrupt multi-byte sequences; should use mb_convert_case or static type mapping
F062 45 correctness storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:1156 008 field uses str_pad with default ' ' (space) padding for year — if anno_pubblicazione is empty, gets ' ' (4 spaces) acceptable; but if shorter than 4 digits (e.g. '999'), the pad makes it ' 999' which violates MARC21 008 format (single-known-date should be 4 zero-padded digits)
F064 45 security storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2630-2680 digitalAssetAddAction stores an admin-supplied URL into digital_assets.url after only filter_var(FILTER_VALIDATE_URL) and http/https scheme check. The URL is later emitted in OAI-PMH MAG elements served publicly. No allow-list of hosts; admins could store URLs pointing to internal infra or mark book metadata with attacker-controlled URLs. Admin-only, so low severity, but worth review.
F066 45 security storage/plugins/oai-pmh-server/OaiPmhServerPlugin.php:2710-2745 digitalAssetAddAction reads JSON body with json_decode((string)$request->getBody()) but does not verify Content-Type, and validates the CSRF token from JSON body (data['csrf_token']) instead of header. Combined with the OPTIONS/JSON path this can allow a same-site fetch with text/plain to bypass Slim's CSRF middleware (which only validates form-encoded POSTs) — though the explicit Csrf::validate inside the action does enforce token presence.
F073 45 security storage/plugins/openurl-resolver/OpenUrlResolverPlugin.php:133-134 injectCoinsScript builds fetch URL by concatenating json_encode(basePath + '/api/coins/book/') + id — basePath from defined('BASE_PATH') ? BASE_PATH : '' but app elsewhere uses HtmlHelper::getBasePath(); a stale BASE_PATH constant will produce wrong fetch path on subfolder installs
F074 45 correctness storage/plugins/openurl-resolver/OpenUrlResolverPlugin.php:327-329 preg_replace coerced via '?? '''; loop continues even when value doesn't normalize to 10/13 digits, so 'isbn'/'rft_id' fields with junk fall through, possibly confusing callers expecting validation
F075 45 security storage/plugins/openurl-resolver/OpenUrlResolverPlugin.php:380-395 localBookUrl() builds the redirect origin from $request->getUri()->getHost() / getScheme(), which derive from the user-supplied Host header. An attacker controlling the Host header can influence the 302 Location origin used for the local-book branch (open-redirect-by-Host-spoofing if Slim/PSR-7 trusts the Host header in this deployment).
F085 45 correctness storage/plugins/z39-server/classes/SRUServer.php:85 NS_DIAG constant changed from 'http://www.loc.gov/zing/srw/diagnostic/' to 'info:srw/diagnostic/1/' — diagnostic uri text content becomes 'info:srw/diagnostic/1/' . $code; existing clients pointing at the old prefix will silently break
F087 45 correctness storage/plugins/z39-server/classes/SRUServer.php:1146-1150 writeAccessLog now early-returns when stmt prepare fails — silently. Repeated access-log failures (e.g. table missing during migration) produce no observable signal
F089 45 security storage/plugins/z39-server/classes/SruClient.php:149-156 Term wrapping for CQL: when server.quote_search_terms is enabled the user-controlled $term is wrapped in CQL double-quotes without escaping embedded '"' characters. A term containing a double-quote can terminate the literal early and inject CQL operators into the outbound SRU query (e.g. to remote BNF/SUDOC).
F090 45 correctness storage/plugins/z39-server/classes/SruClient.php:150-155 After loadXML failure, libxml_use_internal_errors($prevLibxmlErrors) is called inside the if-block, but is also called after — when loadXML fails the restoration line outside is never reached (throws first)

Phase 4 couldn't confirm decisively. Re-run /adamsreview:review if you suspect this deserves
further investigation with fresh context.

Light lane — ux, policy, architecture

Light-lane findings — including rows labeled auto-fixable — aren't applied by /adamsreview:fix directly. Findings with an auto-recommendation get batch-confirmed at :fix's Phase 7.5 preflight (or :walkthrough Step 4.5); use /adamsreview:promote <finding_id> for a single-finding manual override.

# Score Impact File Finding Disposition
F005 60 ux app/Models/Language.php:286-294 setDefault() now flips is_active=1 together with is_default=1. Fix description says 'can't default to inactive language' but prior bug was admins setting inactive language as default and getting locked out. Side effect: setting default also silently re-enables, even if admin had just deactivated it. Two state transitions in one query without audit trail. manual
F014 60 ux app/Views/frontend/catalog.php:306-332 'Trovato anche nell'archivio' suggestion box renders archive results below the empty-catalog state but only when $archiveResults is non-empty — it's outside the empty-state container, which means when the search HAS book results AND archive results, the archive panel still renders after the books but ONLY in the empty branch. Test by reading the surrounding control flow: the box is rendered inside the
that holds the 'Pulisci filtri' button which itself is inside the empty-results branch.
manual
F017 75 ux app/Views/user_layout.php:1215 Archive search-result avatar icon lacks aria-hidden="true", unlike the parallel block in frontend/layout.php which sets it. Screen readers will announce 'archive' twice (icon + label).
F024 75 ux locale/en_US.json:1 New archive form section labels ('Area di identificazione', 'Contenuto e struttura', 'Condizioni di accesso e uso', 'Identificatore ARK', 'Dichiarazione dei diritti (URL)', 'URL manifest IIIF (server esterno)', 'Nota di versione') translated only in fr_FR.json — missing from en_US.json and de_DE.json. EN/DE users will see Italian section headings.
F035 60 ux storage/plugins/archives/views/authorities/show.php:39-48 'Indietro' (back) button uses inline JS history.back() with referrer-origin check, but the same-origin referrer guard is too strict: when the user opens the authority page in a new tab (common from search results), document.referrer is empty and they get sent to the authorities list rather than the search results they came from. Also data-fallback-url is the static list, not the search query. manual
F037 60 ux storage/plugins/archives/views/form.php:783-789 version_note placeholder logic is inverted: $val('version_note') !== '' ? '' : $e(__('es. ...')) — when there is no current value the placeholder shows, when there IS a value the placeholder is blanked. But since already shows the value, the conditional is unnecessary; the bigger issue is the literal year '2024' in the placeholder text is becoming outdated (PR date is 2026). manual
F038 60 ux storage/plugins/archives/views/index.php:1013-1016 Inline <style> block injected mid-page inside an admin layout (#arc-actions-details[open] .arc-chevron) — violates content-security-policy with inline style, and admin pages elsewhere put rules in CSS asset files. Also breaks visual consistency if site CSP is tightened. manual
F039 75 ux storage/plugins/archives/views/index.php:1080-1088 Filtered result label __("livello") ?>: echoes the raw enum value (fonds, series, file, item) instead of the localized label (Fondo, Serie, Fascicolo, Unita) already prepared in $levelLabel for the badge cells. Inconsistent with the option dropdown above which uses localized labels.
F040 60 ux storage/plugins/archives/views/public/index.php:1244-1252 The 'Azzera filtri' (clear filters) button is rendered as a single 'times' character with only a tooltip title=; on touch devices the tooltip is invisible and the glyph alone fails to meet the 44x44 px touch target / accessible-name requirement. The form's submit button has no aria-label either. manual
F041 75 ux storage/plugins/archives/views/public/index.php:1260-1271 Public archive search results have same pluralization issue: '1 risultati' for single match. Date-range fallback uses ellipsis character for missing bound which is unintuitive — better to show 'dal X' / 'fino al Y' explicitly. manual
F043 60 ux storage/plugins/archives/views/public/show.php:1385-1400 Multi-file download list shows MIME type next to each file but no file size and no upload date. For audio files only the player renders; the user cannot see what they're about to listen to (e.g. duration, format) before pressing play. Filename fallback uses basename() which can leak storage paths if original_filename is null. manual
F046 75 policy storage/plugins/ncip-server/NcipServerPlugin.php:17-30 Docblock 'Supported messages' lists only 5 (LookupItem, LookupUser, CheckOutItem, CheckInItem, RenewItem) but match() at lines 432-439 handles 7 — adds RequestItem and CancelRequestItem; capability XML at line 818 advertises all 7 manual
F054 75 ux storage/plugins/ncip-server/views/partners.php:152-161 Destructive partner-delete uses native confirm('Eliminare questo partner?') with no name, ISIL, or endpoint context — admin can't tell which partner is about to be removed and there is no preview of side effects (transactions referencing this partner). Other destructive flows in this codebase use SweetAlert with detail. manual
F055 60 ux storage/plugins/ncip-server/views/transactions.php:93-100 Status badge color-codes 'ok'/'error'/'pending' but the raw $tx['status'] value is rendered () — not translated, so users see the English/protocol status string in all locales. Should map to localized labels matching the colored badge. manual
F069 60 ux storage/plugins/oai-pmh-server/views/book-digital-assets.php:85-90 Icon-only delete button exposes accessible name only via title="Elimina"; HTML title is not consistently announced by screen readers (NVDA/JAWS often ignore it). Should use aria-label="" like other icon-only buttons fixed in this PR (e.g. plugins.php uninstall, z39 server row delete). manual
F070 60 ux storage/plugins/oai-pmh-server/views/book-digital-assets.php:110-162 Add-digital-copy form has no client-side validation of MD5 hash format or image dimensions before submit; the server-side error returns via setError() but the URL input has required only, while MD5/dimensions only pattern/min — no inline error UI, no per-field error placement. Users get one banner error for whichever field failed first. manual
F071 60 ux storage/plugins/oai-pmh-server/views/book-digital-assets.php:254-272 Digital-asset delete uses native confirm() with generic message and shows no URL/filetype/size of the row being removed; after success only the row is spliced from DOM with no toast/feedback. Errors surface via native alert(), inconsistent with the rest of Pinakes which uses SweetAlert and inline alerts.
F076 75 policy storage/plugins/openurl-resolver/wrapper.php:10-11 Comment asserts getPluginClassName('openurl-resolver') returns 'OpenUrlResolverPlugin' (capital U mid-word) but PluginManager::getPluginClassName uses explode('-')+ucfirst per segment, which yields 'OpenurlResolverPlugin' (no second cap). The wrapper class is declared as 'OpenUrlResolverPlugin' and only PHP's case-insensitive class lookup keeps the lookup working manual
F001 60 architecture app/Controllers/FrontendController.php:519-524 catalogAPI() (AJAX/JSON variant) computes $archiveResults and exposes as 'archives' in JSON response, but no current JS consumer reads response.archives — layout.php search dropdown reads from /api/search/unified which uses SearchController, not /api/catalog. catalogAPI changes ship dead data: server cost of plugin hook invocation on every search request with zero observable effect. informational
F019 75 architecture installer/classes/Installer.php:1460-1474 Fresh install's installPluginsFromZip() hardcodes 5-plugin allow-list (open-library, z39-server, api-book-scraper, digital-library, dewey-editor). This PR expands BundledPlugins::LIST to 16 plugins including bibframe-linked-data, ncip-server, oai-pmh-server, openurl-resolver, resource-sync, viaf-authority, archives, deezer, discogs, goodlib, musicbrainz. Upgrade paths get them via migrate_0.7.4.sql; fresh installs only get them later via autoRegisterBundledPlugins() and they land deactivated. Two divergent paths. informational
F013 45 ux app/Views/frontend/book-detail.php:1605-1610 Media-type badge moved out of

into a separate sibling div above the title. Visual hierarchy now reads: badge title authors. But the badge uses font-size: 0.7rem directly in inline style and bg-light text-secondary while the rest of the page uses availability-badge class with stronger contrast. The badge is purely decorative ('Libro', 'CD', etc.) without role/label context — screen reader will read it before the book title with no separator.

uncertain
F036 45 ux storage/plugins/archives/views/form.php:683-701 ARK identifier and IIIF manifest URL inputs accept arbitrary text but help-text below only describes the expected pattern in prose. There is no inline validation (pattern attribute, datalist of common NAANs) — invalid values are only caught server-side, returning user to a fresh form. Should add pattern="^ark:/.+" and/or example datalist for better diagnostic. uncertain
F053 45 ux storage/plugins/ncip-server/views/partners.php:85-91 'Note' partner field has no maxlength visible to user (maxlength="500" is set on the input but no character counter), and the table renders Notes with max-w-xs truncate — long notes are silently cut off with no tooltip or expand affordance, so admins can't see what they typed without editing. uncertain
F056 45 ux storage/plugins/ncip-server/views/transactions.php:111-131 Pagination shows only prev/next buttons and 'Pagina X di Y' text — no page-size selector, no jump-to-page, no per-page count, no link to first/last. For a transaction log this is painful once volume exceeds a few pages. Also no filter by status/partner/date even though those columns are displayed. uncertain

🤖 Generated with Adam's Claude Code Review Command

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants