api_keys_from is a typed setting, lan (default, the historical rule) or anywhere, checked in admin_api._auth against the first X-Forwarded-For hop that NPM sets. It governs API keys only: a session is never restricted, the console is a session from anywhere; and the break-glass token is ALWAYS LAN-only, which is not a choice and so is not a setting. LAN ranges are facts about the network and live in code; the docker range is included because NPM, the routines and sibling containers reach the app from arrstack_arr_net. An unparseable address is not LAN - the rule fails closed. With this in place the location /api/admin/ block on NPM host 51 can come out; the app enforces what it enforced, and the toggle never touches NPM. tests/smoke_admin.py 58 -> 76. Proven on a throwaway site: key LAN 200, key WAN 403 naming the setting, session WAN 200, token LAN 200, token WAN 403; set anywhere, key WAN 200 and token WAN still 403; bogus value 422.
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"}
daterequired (YYYY-MM-DD).endonly 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/site73is 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"}
slugis 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.categorymatches an id in the manifest'scategories; 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 withX-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.
updatedoptional; 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)./contact301s 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 inScouting-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.)