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.
MEETING_DAY, MEETING_DAYS, MEETING_DAY_ABBR, PACK_TIME, TROOP_TIME,
PACK_CLOCK and TROOP_CLOCK were the last thing a leader could not change
without a commit. identity.meeting_words() derives all seven from the units
rows; app.py refreshes the module globals from it on a 30-second TTL in a
middleware, and the page templates - f-strings that read those globals when a
route runs - are untouched. A row that cannot supply a word keeps the
default, so a half-filled unit degrades to today's copy rather than a blank.
The day words come from the pack row: the site's copy assumes both units
meet the same night, and if that changes the copy needs rewriting, not a
bigger constant. Proven on a throwaway container with a DB copy: patching
pack73 to Thursday 18:15 changed the homepage, cubs, troop and meta
description; patching back restored them. tests/smoke_identity.py 59 -> 66.
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.
Adds identity.py (schema, capability map, scrypt passwords, invites,
sessions, login throttle, boot seed) and auth.py (login, invite
acceptance, account page). app.py gains two imports and one wiring
block at EOF; no existing behaviour changes.
Units are a table seeded from the site constants. Roles split: leader
and member per unit in memberships, owner and admin site-wide in
people.global_role, so a unit added later cannot under-grant an admin.
tests/smoke_identity.py covers the rules that are invisible when wrong:
single-use invites, reissue revoking the prior link, expiry, idle and
absolute session bounds, throttling, and the capability split. 49 checks.
The hero button said 'Join us this fall' while the nav button two
inches above it said 'Join us'. That is the whole problem in one
screenshot: strings that go stale on a schedule, scattered.
Seven values at the top of app.py now hold the program year, the
meeting day, and the two meeting times. Twelve inline meeting-time
strings and four program-year labels read from them, so a time change
is one edit rather than a grep. The block carries a note that this is
not a CMS: anything with a date in it belongs in the calendar feed,
and evergreen copy stays inline where git log can answer what the
site said during recruitment.
Two strings were not stale, they were false out of season:
'There is nothing to do between now and Tuesday' on the thanks page
and 'See you Tuesday' on the join CTA both confidently invited
families to a meeting that did not exist through July. Now 'we will
be in touch with the next meeting date' and 'See you on a Tuesday'.
The properly correct fix reads the next meeting from the calendar
feed and this is not that, but it stops the site making a promise
it cannot keep in August.
The chocolate booth does not run all winter. Three places now say
in season.
'fall grade' becomes 'current grade' on the join label and in
SHEET_HEADERS. The header is only written to an empty sheet, so the
live sheet would have kept saying 'fall grade' forever; sheet_append
now repairs cell E1 in place on the next lead, the same way it
already backfills the Comments header.
thanks_body became an f-string. It had no bare braces, so the
conversion is safe.
Two faults on a phone, both found on the live site.
The single-line clamp truncated a venue change at 'the Deep Creek
pa...', cutting the only part of the notice that mattered. Collapsed
now clamps to two lines, three under 560px. A SINGLE notice does not
clamp at all - it has no expand affordance, so clamping it loses text
with no way to get it back.
The banner scrolled away while the nav stayed pinned. That was a
deliberate call and it was wrong: a cancellation is worth more
viewport than the nav is. Banner and nav now share one sticky wrapper
so they pin together.
Expanded is exempt via :has() - an open three-notice banner pinned to
a phone viewport is the thing the original call was trying to avoid,
and it is still right about that. Open scrolls, collapsed sticks. The
40vh cap stays as the floor where :has() is unsupported.
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.
Three bot submissions landed 2026-08-30 with both selects carrying the
placeholder label "Select one..." - required= is browser-only and a direct
POST skips it. Adds a hidden honeypot (answers 303 so the bot sees success)
and server-side checks on name, email, children, interested_in and source.
Rejections are logged with the client IP and never touch any store.
Internal papers need a durable link before member login exists. 'unlisted'
serves a document at its slug but keeps it off /documents and sends
X-Robots-Tag: noindex so it stays out of search results.
Serving and listing are now separate questions: visible() decides whether a
document can be served at all and stays the seam login attaches to, listed()
decides whether it shows on the index. 'members' remains hidden AND
unservable, so the weaker state cannot be mistaken for the gate.
This is obscurity, not access control, and both the README and the manifest
say so. An unlisted link is forwardable.
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.
On a phone the /join grid collapses to one column, so the success
banner rendered below the entire pitch column and the 303 landed the
parent at the top of an apparently unchanged page. The empty form
underneath then read as a failed send.
?sent=1 now returns a dedicated confirmation body: banner first, what
happens next, and no form to resubmit.
Two real leads on 2026-08-26 landed in the DB with no Sheet row and no mirror
row at all: the empty-env window meant the Sheet call never ran. Because
/mirrors/failed looked for state='failed', a mirror that was never attempted
read exactly like one that was never owed, and the summary reported a clean
failed_mirrors: {} while two families were missing from the sheet.
/join now writes a `pending` mirror row for every target BEFORE attempting any
of them, so the gap between "owed" and "done" is a row rather than an absence.
set_mirror_pending never downgrades an attempted mirror. failed_mirror_records
covers failed and pending, since both need replaying, and summary reports
pending_mirrors alongside failed_mirrors.
sheet_append also stamped the Sheet with datetime.now(), so replaying a lead
filed it under the day of the replay rather than the day the family submitted.
It now takes the submission timestamp off the record and only falls back to now
when there isn't one.
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
Pack runs 6:00-7:15, so the troop follows at 7:30 with a buffer. Updates
all eight troop time references across the footer, /troop and /calendar.
The two 7:00 AM references (Green Lane farmers market chocolate booth)
are deliberately unchanged.
Planned timing, not yet established practice.