The site becomes a second writer to scouts/site73, as the scoped scoutsite
principal (rw on that one collection, denied everywhere else by the rights
file). Two rules enforced in calendar_write.py, not left to callers: the
site owns only UIDs ending @site73.greenlanescouts73.org and refuses any
other before a network call, the same shape as band-cal-sync and @band.us;
and with no RADICALE_* configuration every write is a 503, never a silent
no-op.
The VEVENT layout matches seed.py exactly so the feed reverses it into the
row shape the public pages already render: all-day DTEND exclusive, TZID +
VTIMEZONE on timed events, 90-minute default for a timed one-day event,
noon on the end date for a timed multi-day one, CATEGORIES for the unit,
X-SCOUT73-BADGE, 75-octet folding. Reads come from the scout-calendar feed
(now carrying uid and recurring); rows the site created and that are not
part of a series are marked mine. Writes are logged to auth_events and
bust the page cache so a leader sees their event within the feed's minute.
DELETE really deletes - the calendar's history is Radicale's git log.
Endpoints under calendar:write. tests/smoke_admin.py 78 -> 102. Proven
against the real store on a 2036 probe (outside the feed window): create,
read back byte-for-byte, replace, delete, second delete 404, foreign UID
403 with no store call, unconfigured 503.
Portainer stack variables are substitution values for the compose file,
not container environment. A var added to the stack and not listed here
is silently absent at runtime.
The app is the only process on this box accepting unauthenticated input from
the internet and it was running as root in a writable container. Now uid 10001,
read_only with a tmpfs /tmp, cap_drop ALL and no-new-privileges. Host-side
/srv/scout-website/data and the service account key are chowned to 10001.
Verified on a throwaway container: every route, the admin API, spam rejection,
and a real submission writing to both the jsonl and SQLite.
NPM proxies to scout-website:8000 over arrstack_arr_net, so the 0.0.0.0 publish
only provided a LAN path that bypassed the proxy, its exploit blocking and its
real-client-IP logging.
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.
Events now come from EVENTS_FEED_URL (the scout-calendar container,
which serves Radicale mike/site73 as JSON). The last good copy is
cached to /data/events-cache.json and served whenever the feed is down
or empty; 'Nothing on the books' remains the cold-start floor only.
The round-trip test proved the feed reproduces all 32 rows of
app/events.json byte-identically, so nothing visible changes today.
Removes app/events.json, the first-boot seed copy, and the local load
path; README calendar section rewritten; compose gains EVENTS_FEED_URL
from the Portainer stack env.
Form submissions now land in /data/scout73.db before anything leaves the
box. The Google Sheet and the ntfy push become mirrors whose per-record
outcome is recorded, so a failed sheet write is replayable instead of
surviving only as a push telling you to retype it.
The table is a generic record store keyed by 'kind', with workflow columns
and an audit trail, so RSVPs and other forms can land in the same place
later without a migration. Admin panel talks to /api/admin over HTTP and
never opens the DB file.
- app/store.py schema, writes, reads, one-time leads.jsonl backfill
- app/admin_api.py token-gated API; fails closed when ADMIN_TOKEN is unset
- app/app.py three hunks: imports, boot init, join_post
- compose ADMIN_TOKEN passed through from the Portainer stack env