Lane 8 PR 5: handouts, private files, rules' Make it ours (#734) #893

Merged
claude-bot merged 9 commits from build/lane8-5-handouts into main 2026-09-28 04:13:15 +00:00
Collaborator

Part of #734 (lane 8), §3.8 and §3.10. Stacked on #890 (PR 4). Server and bot; no pages.

Migration 8a7b8c9d0e1f_handouts_reveals (on 8f6a)

  • lore_attachments (image, PDF or text; random path; PDF page count).

  • lore_entries.source_reference_entry_id, for "Make it ours".

  • shelf_reveals gains:

    • a page, a piece label and an audience (the table, a party, or chosen people, via shelf_reveal_audience_people);
    • session_id becomes nullable (W8).

    Every column is ADD/DROP COLUMN IF EXISTS, so either order with lane 10's 10b2 on the same table works.

  • CHECK constraints: ck_notifications_kind gains handout_shown and ck_notifications_subject_type gains lore_entry. Both keep the union of what came before.

  • Up → down → up passes with seeded rows, including a reveal made outside play, which the downgrade deletes before restoring NOT NULL.

  • One cross-migration test fix: PR 3's parties downgrade drops shelf_reveals.audience_party_id if it's present.

Private files (W7): the security surface

  • Storage: media/private/campaigns/{cid}/files/{uuid}.{ext}. It's outside anything the public /media/ location will serve: location ^~ /media/private/ { return 404; }.

  • Download: GET /api/campaigns/{cid}/files/{id}. The backend authorises every request:

    • a GM always may;
    • anyone else may if the page's audience includes them, or any live reveal's audience does.

    It then answers X-Accel-Redirect: /internal-files/.... That nginx location is internal and aliases only private/.

  • Cache-Control: private, no-store for anything narrower than the table.

  • Proven against a real frontend-prod image:

    Request Result
    the public /media/private/... URL 404
    /internal-files/... directly 404
    the authorised route 200 with the file
    ordinary public images still 200
  • Tests:

    • authorisation: outsiders get 404; revealing opens a file and revoking closes it again; a reveal to one player doesn't open it for another;
    • files are never written outside private/;
    • cross-tree checks that nginx's alias and the refusal block exist.
  • Uploads: magic-byte validation, PDFs checked with pypdf (new dependency, pypdf==5.1.0), and images re-encoded.

(This was caught in review: the first version stored files under the public /media/ alias.)

The rest

  • Reveals: create, revoke and list.
  • handout_shown as a bell kind (category wiki), whose sentence carries the title only, never the body.
  • Discord: table and party reveals are posted to the channel with the file attached, or with a link if it's over Discord's 8 MB limit. A reveal to chosen people is DMed to each linked person.
  • GET /api/bot/files/{id} (bot-auth), plus the bot's handler. Additive; the contract stays at v2.
  • POST /reference/{id}/make-ours (GM-only) creates a GM-only page linked to its source.

Departures and follow-ups

  • W8's "between sessions" label is the pages' job. The server returns session_id: null for a reveal made outside play.
  • A reveal to chosen people silently skips anyone not linked to Discord. They still get the bell, but the GM isn't told at reveal time.
  • Page pictures narrower than the table are still stored publicly at /media/lore/{id}/image.*. That predates this PR, and it's filed separately.

Checks

  • Full backend suite: 5558 passed. The only failures happen locally, because the harness mounts webapp/backend alone.
  • Bot suite: 545 passed.
  • vitest: 2606 passed. eslint: 0 errors.
  • ruff: clean. Version sync: OK.
  • Changelog: a changelog.d/ fragment.

🤖 Generated with Claude Code

Part of #734 (lane 8), §3.8 and §3.10. **Stacked on #890 (PR 4).** Server and bot; no pages. ## Migration `8a7b8c9d0e1f_handouts_reveals` (on `8f6a`) - **`lore_attachments`** (image, PDF or text; random path; PDF page count). - **`lore_entries.source_reference_entry_id`**, for "Make it ours". - **`shelf_reveals`** gains: - a page, a piece label and an audience (the table, a party, or chosen people, via `shelf_reveal_audience_people`); - `session_id` becomes nullable (W8). Every column is `ADD`/`DROP COLUMN IF EXISTS`, so either order with lane 10's `10b2` on the same table works. - **CHECK constraints:** `ck_notifications_kind` gains `handout_shown` and `ck_notifications_subject_type` gains `lore_entry`. Both keep the union of what came before. - **Up → down → up passes with seeded rows,** including a reveal made outside play, which the downgrade deletes before restoring `NOT NULL`. - **One cross-migration test fix:** PR 3's parties downgrade drops `shelf_reveals.audience_party_id` if it's present. ## Private files (W7): the security surface - **Storage:** `media/private/campaigns/{cid}/files/{uuid}.{ext}`. It's outside anything the public `/media/` location will serve: `location ^~ /media/private/ { return 404; }`. - **Download:** `GET /api/campaigns/{cid}/files/{id}`. The backend authorises **every request**: - a GM always may; - anyone else may if the page's audience includes them, or any **live** reveal's audience does. It then answers `X-Accel-Redirect: /internal-files/...`. That nginx location is `internal` and aliases only `private/`. - **`Cache-Control: private, no-store`** for anything narrower than the table. - **Proven against a real frontend-prod image:** | Request | Result | |---|---| | the public `/media/private/...` URL | 404 | | `/internal-files/...` directly | 404 | | the authorised route | 200 with the file | | ordinary public images | still 200 | - **Tests:** - authorisation: outsiders get 404; revealing opens a file and revoking closes it again; a reveal to one player doesn't open it for another; - files are never written outside `private/`; - cross-tree checks that nginx's alias and the refusal block exist. - **Uploads:** magic-byte validation, PDFs checked with `pypdf` (new dependency, `pypdf==5.1.0`), and images re-encoded. *(This was caught in review: the first version stored files under the public `/media/` alias.)* ## The rest - **Reveals:** create, revoke and list. - **`handout_shown`** as a bell kind (category `wiki`), whose sentence carries the title only, never the body. - **Discord:** table and party reveals are posted to the channel with the file attached, or with a link if it's over Discord's 8 MB limit. A reveal to chosen people is DMed to each linked person. - **`GET /api/bot/files/{id}`** (bot-auth), plus the bot's handler. **Additive; the contract stays at v2.** - **`POST /reference/{id}/make-ours`** (GM-only) creates a GM-only page linked to its source. ## Departures and follow-ups - **W8's "between sessions" label is the pages' job.** The server returns `session_id: null` for a reveal made outside play. - **A reveal to chosen people silently skips anyone not linked to Discord.** They still get the bell, but the GM isn't told at reveal time. - **Page pictures narrower than the table** are still stored publicly at `/media/lore/{id}/image.*`. That predates this PR, and it's filed separately. ## Checks - **Full backend suite:** 5558 passed. The only failures happen locally, because the harness mounts `webapp/backend` alone. - **Bot suite:** 545 passed. - **vitest:** 2606 passed. **eslint:** 0 errors. - **ruff:** clean. **Version sync:** OK. - **Changelog:** a `changelog.d/` fragment. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Server: attachments (lore_attachments), PDF validation via magic bytes and
pypdf page counts, the private files route (X-Accel-Redirect + nginx's
internal location, W7), reveals with audience/page/nullable session (W8),
handout_shown bell kind, and reference/{id}/make-ours. Bot: GET
/api/bot/files/{id} (additive, contract stays v2) and the handout_shown
channel/DM handler. Migration 8a7b8c9d0e1f on 8f6a7b8c9d0e; up/down/up
proven on real rows. Targeted backend suite green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
handout_shown/lore_entry widen ck_notifications_kind/ck_notifications_subject_type
again, so every earlier migration's "historical fact" test needs the same
exclusion the last widen already used (test_safety_migration,
test_server_owner_migration, test_discussion_notifications_migration,
test_outbound_notifications_migration, test_notifications). Also adds
api_client.get_file and notifications cog coverage (channel post with
attachment, DM to a chosen audience, Discord's upload cap, a revoked/missing
file, a transient fetch failure). Full backend and bot suites green; two
pre-existing test_backup_client_version.py/test_safety_anonymity.py failures
are this Docker mount only exposing webapp/backend (same class as the four
tests --ignore'd for the same reason), not caused by this change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
files_service stored attachments at media_root/campaigns/{cid}/files/..., a
subtree nginx's public `location /media/` (Cache-Control: public,
max-age=31536000, immutable, no auth) already serves whole. The uuid name
was no protection: a revoked reveal's link kept working forever, and anyone
the URL reached could read the file with no backend check at all.

Move every attachment one level down, to media_root/private/..., which
`location ^~ /media/private/ { return 404; }` refuses outright, independent
of and in addition to nginx's `internal` location (now aliased into the same
private/ subtree) that already gated the X-Accel-Redirect path.
LoreAttachment.path is unaffected (still campaign/kind-shaped, no schema
change); files_service.absolute_path and internal_redirect_path are now the
only two places that add the private/ prefix back on.

Proven against a real frontend-prod image + a stand-in backend: a direct
GET on the public /media/private/... path 404s, GET /internal-files/...
directly still 404s, the X-Accel-Redirect path still serves the file at 200
with the backend's own Cache-Control intact, and an ordinary public /media/
picture is unaffected. New tests assert save_attachment never writes outside
media_root/private, that absolute_path/internal_redirect_path agree with it,
and (skipped outside a full checkout) that nginx.conf carries both halves of
the fix. Page pictures aren't stored privately by any path today, so item 5
of the review doesn't apply yet — flagged for whichever PR does that.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Lane 10's reveal_taken_back (10b2c3d4e5f6) now sits before 8a7b8c9d0e1f on
the chain, so:

* 8a7b's KINDS_SQL_BEFORE is exactly 10b2's KINDS_SQL (it was 10a1's, which
  would have dropped reveal_taken_back from the CHECK on upgrade), and its
  KINDS_SQL is that plus handout_shown. Subject types are unchanged: 7f2d's
  is still the last widen before it.
* NotificationKind lists reveal_taken_back before handout_shown, the chain's
  order; CATEGORY_FOR_KIND, _sentence, the bot's handlers and conftest, and
  AppFrame's NOTE_KIND carry both.
* Every historical-fact kind test excludes both later kinds;
  test_reveal_discord_message_migration's union test becomes one too, and
  tests/wiki/test_handouts_migration now holds both of 8a7b's CHECKs to the
  model and their downgrade lists to 10b2's and 7f2d's.
* The bot's notifications cog keeps main's retired session_summarised and
  the reveal_id report in _handle_shelf_reveal, then lane 8's handout
  handlers; the datetime import main dropped stays dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main moved every post's colour into questboard_bot.faces (lane 10, EXT-2)
and dropped the module's _COLOR_* constants, so lane 8 PR 5's handout
handlers raised NameError after the merge (7 bot tests). faces.TONES
["reveal"] is the same purple (0x9B59B6), so nothing a player sees changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /api/sessions/{id}/revealed is readable by every member and listed every
live shelf_reveals row on the session. Lane 8 PR 5 put handout reveals, each
with an audience of its own, on that same table, so a handout shown to one
player, or to the GMs only, had its title listed for the whole table.
tests/wiki/test_handouts_api.py reproduced it (marcus saw a reveal chosen for
priya) before the fix.

* shelf_service.list_reveals now takes a required viewer and keeps a handout
  reveal only for the people in its audience and the GMs, through
  handout_service.reveal_visible_to: the rule may_download already applied
  to the file, which now uses the same predicate. A shelf card's reveal is
  still the whole table's. This follows the survey's "revealed through the
  existing shelf-reveal mechanism" (WIKI-14/TABLE-1): handouts stay in the
  session's list for their audience, rather than being taken out of it.
* A handout reveal's session_id must be one of the campaign's own sessions
  (422 otherwise), or a GM could list a title on another table's page.
* 8a7b8c9d0e1f's downgrade deletes every handout reveal (lore_entry_id set),
  not only sessionless ones: the older schema has no reveal audience and
  would show the title to everyone.
* 8a7b8c9d0e1f creates the three indexes the model declares and the
  migration lacked (ix_shelf_reveals_lore_entry_live, partial on
  revoked_at IS NULL, and the people table's user_id/character_id), and
  drops them on the way down. A test takes the migration down and up and
  holds every index on both tables to the model's definitions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude-bot changed target branch from build/lane8-4-quests to main 2026-09-28 03:20:02 +00:00
Merge main into build/lane8-5-handouts
All checks were successful
CI / Backend lint (ruff) (pull_request) Successful in 33s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 55s
CI / Bot/backend version sync (pull_request) Successful in 23s
CI / Feedback site tests (pull_request) Successful in 39s
CI / Does this change touch the recording path? (pull_request) Successful in 16s
CI / Docker image build (pull_request) Successful in 1m4s
CI / Bot tests and audit (pull_request) Successful in 5m15s
CI / Frontend tests, audit, and build (pull_request) Successful in 21m10s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 13m50s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 20m30s
CI / Backend migration, tests, and audit (pull_request) Successful in 46m26s
ee7d9f5f2d
Sign in to join this conversation.
No description provided.