Commit Graph
8 Commits
Author SHA1 Message Date
thethreemagi e52cc60fcf P4: calendar write-back to Radicale, site-owned UIDs only
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.
2026-09-04 17:56:55 -04:00
thethreemagi 69b177d519 the LAN rule moves from nginx into the app, as a setting for keys only
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.
2026-09-04 17:45:17 -04:00
thethreemagi 4b5b2a3667 P3: API keys, bearer auth on the admin API, whoami, generated /api/docs
A key is the person who minted it, narrowed to the scopes they chose. Only
the sha256 is stored; the full key is returned once. Scopes must be a subset
of the owner's capabilities at mint time and are enforced again at use time
inside identity.can(), the one place that decides, so a key never outlives
its owner's demotion and disabling a person disables their keys with no
separate flag. A key cannot carry apikeys:own or the owner powers, so it
cannot mint keys. Revoked rows stay; a foreign key id is 404, never 403.

Bearer keys are honoured ONLY on /api/admin. The rest of the site reads
sessions alone, so a scoped key never widens into a browser identity.
X-Admin-Token remains break glass and, having no person, cannot own a key.

/api/docs is generated from the router on every request: path, methods and
docstring from the route objects, and the capability read out of each
handler's own _auth() call so it cannot drift from the check. Gated on a new
api:docs capability (leader and above). GET /api/admin/whoami answers who the
API thinks you are and what you can do.

Tests: smoke_identity 66 -> 92, smoke_admin 53 -> 58 (registry has a
capability for every route, docs page renders every route). Driven end to
end on a throwaway site with a DB copy: mint, whoami via key, scoped 200s
and a 403 that names the narrowing, key-mints-key 403, garbage key 401,
admin token on /keys 403, key on /account is not a session, revoke then
401, second revoke 409.
2026-09-04 17:31:05 -04:00
thethreemagi 32e1c67a6f P2: nearby units CRUD, unit meeting times, site settings - the first admin writes
Nearby rows bump verified_at on every save and deactivate rather than delete.
Unit edits are meeting fields only, gated by unit:write_own scoped to the unit
in the URL. Settings are a typed key registry falling back to code defaults;
find-a-unit now reads its source name and URL from it. link_url and contact
reject attribute-breakout characters and the nearby renderer escapes quotes.
Covered by tests/smoke_admin.py, 53 checks in-process.
2026-09-04 14:10:29 -04:00
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
claude e02125392a Announcements: a site-wide notice with a start and an end
The one string on this site where a deploy is the wrong latency is
"tonight's meeting is cancelled, the lot is flooded" at 4pm on a
Tuesday. That is a record with a lifecycle, not site copy, so it gets
a table and a write path rather than a commit.

store.py
  announcements table, created by the existing IF NOT EXISTS path so
  there is no migration. ends_at is REQUIRED: an announcement that
  never expires is site copy, and site copy belongs in the repo where
  it has a diff. Nothing is hard-deleted; taking one down early sets
  revoked_at, so what the site said and when survives.

  Ranking is urgent first, then most recent. Recency alone would let a
  routine Wednesday notice bury a Tuesday cancellation still live.

  Two caps, enforced here rather than in the route so the future panel
  inherits them: 200 characters, and 3 live at once. Both reject rather
  than truncate. Clipping a cancellation mid-sentence is worse than
  making someone shorten it, and a 4th live notice is a signal nobody
  is expiring things rather than something to render.

app.py
  announcement_bar() above the sticky nav. Native <details>, no
  JavaScript, which matters on a read-only rootfs with no build step.
  Collapsed clamps to one line with a count; expanded lists all of
  them and caps at 40vh. One notice renders with no chevron and no
  count: the common case must not look like a widget.

  FAILS OPEN. The admin API fails closed because it serves family
  phone numbers. This is the opposite case, and a broken announcement
  must never take down the public homepage.

  Colours are the existing note and rust token pairs from the brand
  standard. No new colour enters the palette.

admin_api.py
  GET/POST/DELETE on /api/admin/announcements. Leads stay read-only;
  announcements are the deliberate exception, because being mutable
  and expiring is the entire feature rather than a guess at a process.
  list returns a computed state per row (live, scheduled, expired,
  revoked, over_cap) so "why is my notice not showing" is answerable
  from the API and not from the homepage.
2026-09-01 19:22:04 -04:00
Mike Wichers 781f991fbe Replace the generic records table with a lean join_leads table
records was a leads table wearing a generic name. It carried display_name /
email / phone, which only mean anything for a lead, plus status / assigned_to /
notes, which were a guess at an outreach process that has not been designed.
Contacting a family is one-to-many, so three columns on the lead row was always
the wrong shape for it.

join_leads is one row per /join submission with one column per form field, two
timestamps, and payload holding the submission verbatim. Outreach gets its own
table when the process is actually known.

Also splits heard_from from heard_from_detail. app.py folded source_place /
source_other into a composed "Other: ..." string and threw the raw answer away,
which is the half that tells you which daycare the lead came from. The composed
value is still built for the Sheet and the ntfy push.

admin API: /records -> /leads, and PATCH is gone since a lead now has nothing
mutable on it. mirrors and meta are unchanged.
2026-08-26 20:55:58 -04:00
Mike Wichers be2d81b3ab Lead pipeline: local SQLite store as source of truth
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
2026-08-26 19:38:33 -04:00