thethreemagi 8c8dab12e3 P1: gate members documents per unit, session auth on the admin API
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.
2026-09-04 12:09:19 -04:00

scout-website

Public recruiting site for Cub Scout Pack 73 and Scouts BSA Troop 73 (Zieglerville / Green Lane, PA). Live at https://greenlanescouts73.org (NPM proxy → scout-website container, port 8132 on arrstack). Replaces the scoutpoc proof of concept (claude-workspace/stacks/scoutpoc).

Updating the calendar (the part you'll actually do)

All dates on the site (home "Next up," both program pages, the Calendar page) come from the scout-calendar feed (claude-workspace/stacks/scout-calendar), which serves the Radicale collection mike/site73 as JSON:

EVENTS_FEED_URL          ← Portainer stack env; http://scout-calendar:8000/events.json
/data/events-cache.json  ← last good copy, served automatically whenever the feed is down

Edit the calendar in any CalDAV client pointed at Radicale (or ask Claude), and the site follows within ~5 minutes. No file edits, no rebuild, no redeploy. The public ICS families can subscribe to is https://calendar.greenlanescouts73.org/calendar.ics.

Event fields the feed emits (one object per event):

{"date": "2026-10-17", "end": "2026-10-18", "time": "from 4:00 PM",
 "title": "Fall Campout", "location": "Green Lane Park",
 "unit": "both", "badge": "OVERNIGHT"}
  • date required (YYYY-MM-DD). end only for multi-day events.
  • unit: "pack", "troop", or "both" (default pack). Drives the Pack/Troop calendar filter and each page's "Next up."
  • badge: optional shouty label (FALL OPEN HOUSE, OVERNIGHT, THE BIG ONE).
  • Past events are hidden from "Next up" automatically; the Calendar page shows the whole program year.
  • TeamSnap (team 8198615) stays the source of truth for registered families; Radicale mike/site73 is the source for this public site.

Documents

Files at /srv/scout-website-assets/docs (bind-mounted read-only at /docs), published by manifest.json in that same folder. Adding a document is a file drop plus a manifest entry: no rebuild, no redeploy. The app re-reads the manifest whenever its mtime changes.

{"slug": "permission-slip", "file": "2026-permission-slip.pdf",
 "title": "Activity permission slip", "description": "One per scout, per outing.",
 "category": "forms", "unit": "both", "visibility": "public", "updated": "2026-08-30"}
  • slug is the permanent URL: greenlanescouts73.org/documents/<slug>. Never change one that has been printed or emailed. To publish a new version, point the same slug at the new filename.
  • category matches an id in the manifest's categories; anything else lands under "Everything else".
  • visibility:
    • public - listed on /documents, open to anyone.
    • unlisted - served at its slug to anyone holding the link, kept off the index, sent with X-Robots-Tag: noindex. For internal papers that need a durable link before login exists. This is obscurity, not access control. An unlisted link is forwardable; assume it will be.
    • members - hidden and unservable, returns 404. This is not a working gate yet, it is the seam login will attach to.
  • updated optional; without it the file's own mtime is shown.

Documents are served through the app (/documents/{slug}), never from a static mount. Anything under /app/static is public forever, so nothing that will ever need gating goes there. When member login exists it plugs into documents.visible() and no public URL moves.

Layout

  • app/app.py — FastAPI app, all pages server-rendered (Home, /cubs, /troop, /calendar?unit=, /join, /documents). /contact 301s to /join.
  • app/documents.py — the document shelf: manifest loading, the slug-to-file map, and the one visibility gate.
  • docker-compose.yml — Portainer Repository stack definition (port 8131).
  • Images: not in the repo. Bind-mounted read-only from /srv/scout-website-assets/img. Source photos live on the NAS (Scouting-Recruitment-Images, scouting-comms/pack-73/assets/social); web-sized copies in Scouting-Recruitment-Images/_web.

Leads

/srv/scout-website/data/leads.jsonl — one JSON line per interest-form submission, plus an ntfy push per lead.

Lead pipeline (self-contained)

/join submissions write directly to the shared Google Sheet (SHEET_ID stack env, creds bind-mounted from /srv/scout-website/secrets/service_account.json) and push to the shared ntfy scouting topic (NTFY_BASE/NTFY_TOPIC/NTFY_TOKEN). The extra comments box lands in a new 8th sheet column, Comments. Every submission also appends to /data/leads.jsonl; if both sheet and push fail, a warning push goes to the fallback NTFY_URL topic. No dependency on the scoutpoc or scouting73-form containers: both can be decommissioned without breaking this site. (The flyer QR domain scouting73.thewichersfamily.com should be repointed here before its container is retired.)

S
Description
greenlanescouts73.org - Pack & Troop 73 recruiting site
Readme
1.5 MiB
Languages
Python 99.9%
Dockerfile 0.1%