documents.visible() now takes the viewer's unit set. A members document is
served and listed only to a signed-in member of the matching unit; 'both'
reaches any member; owner and admin reach everything including unit types
added later. Everyone else gets 404, never 403.
The gate moved INSIDE find(), so there is one path from a slug to a file and
no route can forget to check. listed() and visible() stay separate functions.
admin_api takes a session first and falls back to X-Admin-Token as break
glass. Still fails closed: no session and no ADMIN_TOKEN is 503. Capability,
not role, decides per route. announcements.created_by now comes from the
session and ignores any value in the request body.
tests/smoke_documents.py, 24 checks, including the invariant that the index
can never list something serving would refuse.
Internal papers need a durable link before member login exists. 'unlisted'
serves a document at its slug but keeps it off /documents and sends
X-Robots-Tag: noindex so it stays out of search results.
Serving and listing are now separate questions: visible() decides whether a
document can be served at all and stays the seam login attaches to, listed()
decides whether it shows on the index. 'members' remains hidden AND
unservable, so the weaker state cannot be mistaken for the gate.
This is obscurity, not access control, and both the README and the manifest
say so. An unlisted link is forwardable.
Files live outside the repo at /srv/scout-website-assets/docs (bind-mounted
read-only at /docs), the same arrangement as the photos, published by a
manifest.json beside them. Adding a document is a file drop plus a manifest
entry - the app re-reads the manifest on mtime change, so no rebuild and no
redeploy.
The manifest maps a stable slug to a filename, so next year's permission slip
can replace this year's without breaking a link already printed on a flyer.
Documents are served through /documents/{slug} rather than from a static
mount, and NOT placed under /app/static, which is public forever. That is the
point: when member login exists it plugs into documents.visible() and no
public URL moves. The visibility field already carries 'members', which today
is hidden from the index and 404s rather than 403s, since a 403 would
advertise a document we cannot yet gate.