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.
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.
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.
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.
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.
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