Pathbuilder import, backend: the sheet PDF and the build JSON (#958) #1361

Merged
claude-bot merged 1 commit from feat/958-pathbuilder-import-backend into main 2026-11-07 00:53:01 +00:00
Collaborator

Backend half of Pathbuilder import (#958, IS-429, D82). The page that uses it follows in a separate PR.

Routes (GM, or the player of the character's page; the campaign must play Pathfinder 2e):

  • POST /api/campaigns/{cid}/lore/{entry_id}/sheet/import/pathbuilder: body {"json": <build or its text>, "code": "484225"?} (1 MB cap).
  • POST .../sheet/import/pathbuilder/pdf: multipart file (a .pdf, 10 MB cap, judged on Content-Length and as it streams, before it is held); optional ?code=.
  • Both: no commit = preview, nothing saved; ?commit=1&expected_version=N saves; &replace=1 confirms replacing a sheet that is not from this character. Answer: {mode: create|update|replace, committed, source_ref, sheet_id, version, needs_confirmation, data, values, changes, report: {read, not_read, warnings, entries, checks, counts}, sheet?}. Documented in docs/API.md.

Design

  • Mapping is data: imports.pathbuilder in app/sheets/builtin/pf2e/character.json (D82, "through the pack's maps"); a test holds every path it names to the definition. Pure readers in app/sheets/importers/.
  • Re-import updates the same sheet when source_ref (kind pathbuilder, id if both have one, else name) matches; rows an import made are replaced, hand-added rows stay; a sheet from elsewhere needs replace=1 and its earlier state stays in the version history (import kind).
  • Feats, class features, items, spells are matched by name through the same search the pickers use; unmatched are kept as text and listed. Bonus hit points are folded into Ancestry/Class hit points so HP matches. checks hold Pathbuilder's own numbers against what Quest Board works out (a difference is a warning).
  • The server never calls pathbuilder2e.com (Cloudflare challenge). Note for the frontend: connect-src 'self' in webapp/frontend/nginx.conf blocks https://pathbuilder2e.com; the browser fetch needs that origin added.

Security

  • GM locks: sheet-level and definition-level (_locked_by) are checked before any row is removed or written; a locked list's rows are untouched and listed in the report; a GM is never locked out.
  • PDF: pypdf form fields only, never rendered or run; encrypted refused; size, 12 pages, 5,000 fields caps. Reads run on a dedicated 2-thread executor behind a gate of 4: a full gate is a 503, and a read the request gave up on keeps its place until it ends, so a stalled parse cannot starve the default pool.

Tests: tests/sheets/test_pathbuilder_import.py (47): full mapping of the sample JSON and PDF fields, a synthetic AcroForm PDF end to end, garbage/oversize/non-PDF/encrypted/not-the-sheet, permissions, preview saves nothing, confirm saves, re-import updates, replace, locks (sheet and definition level), gate and timeout.

No migration; no bot API change.

🤖 Generated with Claude Code

Backend half of Pathbuilder import (#958, IS-429, D82). The page that uses it follows in a separate PR. **Routes** (GM, or the player of the character's page; the campaign must play Pathfinder 2e): - `POST /api/campaigns/{cid}/lore/{entry_id}/sheet/import/pathbuilder`: body `{"json": <build or its text>, "code": "484225"?}` (1 MB cap). - `POST .../sheet/import/pathbuilder/pdf`: multipart `file` (a `.pdf`, 10 MB cap, judged on Content-Length and as it streams, before it is held); optional `?code=`. - Both: no `commit` = preview, nothing saved; `?commit=1&expected_version=N` saves; `&replace=1` confirms replacing a sheet that is not from this character. Answer: `{mode: create|update|replace, committed, source_ref, sheet_id, version, needs_confirmation, data, values, changes, report: {read, not_read, warnings, entries, checks, counts}, sheet?}`. Documented in docs/API.md. **Design** - Mapping is data: `imports.pathbuilder` in `app/sheets/builtin/pf2e/character.json` (D82, "through the pack's maps"); a test holds every path it names to the definition. Pure readers in `app/sheets/importers/`. - Re-import updates the same sheet when `source_ref` (kind pathbuilder, id if both have one, else name) matches; rows an import made are replaced, hand-added rows stay; a sheet from elsewhere needs `replace=1` and its earlier state stays in the version history (`import` kind). - Feats, class features, items, spells are matched by name through the same search the pickers use; unmatched are kept as text and listed. Bonus hit points are folded into Ancestry/Class hit points so HP matches. `checks` hold Pathbuilder's own numbers against what Quest Board works out (a difference is a warning). - The server never calls pathbuilder2e.com (Cloudflare challenge). Note for the frontend: `connect-src 'self'` in `webapp/frontend/nginx.conf` blocks `https://pathbuilder2e.com`; the browser fetch needs that origin added. **Security** - GM locks: sheet-level and definition-level (`_locked_by`) are checked before any row is removed or written; a locked list's rows are untouched and listed in the report; a GM is never locked out. - PDF: pypdf form fields only, never rendered or run; encrypted refused; size, 12 pages, 5,000 fields caps. Reads run on a dedicated 2-thread executor behind a gate of 4: a full gate is a 503, and a read the request gave up on keeps its place until it ends, so a stalled parse cannot starve the default pool. **Tests**: tests/sheets/test_pathbuilder_import.py (47): full mapping of the sample JSON and PDF fields, a synthetic AcroForm PDF end to end, garbage/oversize/non-PDF/encrypted/not-the-sheet, permissions, preview saves nothing, confirm saves, re-import updates, replace, locks (sheet and definition level), gate and timeout. No migration; no bot API change. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(webapp): Pathbuilder import, backend: the sheet PDF and the build JSON (#958)
All checks were successful
CI / Does this change touch the recording path? (pull_request) Successful in 20s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Which journeys does this change need? (pull_request) Successful in 21s
CI / Bot/backend version sync (pull_request) Successful in 26s
CI / Backend lint (ruff) (pull_request) Successful in 31s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 59s
CI / Feedback site tests (pull_request) Successful in 1m28s
CI / Bot tests and audit (pull_request) Successful in 1m59s
CI / Docker image build (pull_request) Successful in 4m54s
CI / Frontend (shard 2/2) (pull_request) Successful in 7m19s
CI / Frontend (shard 1/2) (pull_request) Successful in 12m2s
CI / End-to-end journeys (desktop) (pull_request) Successful in 11m12s
CI / Backend migration, tests, and audit (pull_request) Successful in 35m26s
c9a22203b3
Preview then confirm routes under /lore/{entry_id}/sheet/import/pathbuilder
(JSON) and .../pdf; the mapping is the pf2e definition's imports.pathbuilder
map; app/sheets/importers/ reads (pure), app/services/sheet_import.py matches
names to the campaign's rules, honours GM locks and saves or updates by
source_ref provenance. PDF reads are bounded (own executor, 503 when full,
spooled upload with a size cap). The server never fetches pathbuilder2e.com.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.