Commit Graph
22 Commits
Author SHA1 Message Date
thethreemagi b84fc7f064 fb_posts: edit and repost through the publisher's signed reschedule
POST /fbposts/{id}/reschedule {message, scheduled_for}. The site derives
the per-post signature from the stored cancel link and asks the publisher
to delete the post on Facebook and redraft the queue file; the row goes
to drafted with no Facebook id and no cancel link until the publisher's
next cycle reports the new one. Times must be at least 15 minutes out,
with an offset. An explicit null now clears fb_post_id on upsert; an
absent one still keeps it. tests/smoke_admin.py 160 -> 162.
2026-09-04 19:57:40 -04:00
thethreemagi ea22ed53e7 fb_posts: past its publish time, a scheduled post reads as published
Facebook does not call back and the reconciliation was never built, so a
row that stayed scheduled forever was a lie of omission. Decided by Mike:
once the time passes with nothing else reported, the post is published.
status keeps what the publisher last said; state is what a reader acts
on, and the open-only list and cancel use state. Cancel on a post whose
time has passed says so.
2026-09-04 19:53:08 -04:00
thethreemagi 55e4c67b9a facebook posts: fb_posts table, ingest with hash-verified images, gated image, cancel
Open item 12, designed 2026-08-26, built today. scout-publisher reports
every post it drafts, schedules, holds or cancels by POSTing here; it
never opens the database. id is <unit>/<queue-stem>, stable across body
edits, so a redrafted post is one row and cancel is an indexed lookup on
fb_post_id. The image is copied, content-addressed at
/data/post-images/<sha256>.<ext>, and the sha256 is recomputed on arrival
- a mismatch is refused, so a row never claims a version nobody sent.
image_ref keeps the NAS path as provenance. The image route runs the same
capability check as the list on every request. Cancel goes through the
publisher's own signed per-post link stored on the row; the site never
holds the publisher's secret. fbposts:read for leaders, fbposts:ingest
for admins and scopable so the publisher's key carries exactly that.

tests/smoke_admin.py 147 -> 157.
2026-09-04 19:46:44 -04:00
thethreemagi 7f04d57665 family link: which accounts belong to a household, and GET /api/admin/family
household_people joins a roster family to the accounts of its parents; a
household can have two, a person can rarely be on two. PUT
/roster/households/{id}/people sets it (admin and above). GET /family
(account:self) returns the signed-in person's own households with scouts
and this year's checklist, and nothing else - the parent view of the
roster, read only. Inactive households drop off it.

tests/smoke_admin.py 141 -> 147.
2026-09-04 19:19:34 -04:00
thethreemagi 504538567f roster: households, scouts, a per-year checklist, lead import
Decided by Mike 2026-09-04. my.scouting stays the record of registration;
this holds what a den leader needs on a Tuesday: the families, which scout
is in which den, and a per-program-year checklist of things collected -
dues paid, health form handed in - recording THAT a thing was collected,
by whom and when, never the thing. Health forms are never stored here;
that is a policy, not a gap. A scout is a first name, last name, unit,
den and an optional BSA member ID (the recharter join key), and nothing
else: no date of birth, no address, nothing medical, and a test asserts
no such column exists.

A join lead can be imported as a household: contact copied, the children
field carried as a note to sort by hand, the lead linked and untouched.
Importing twice is 409.

roster:write for leader and above, never on a script key. Every write
lands in the action log. scout-website-backup.timer already copies the
database nightly, which was the doc's first condition for naming scouts.

tests/smoke_admin.py 122 -> 141.
2026-09-04 18:56:30 -04:00
thethreemagi 59559ad909 lead claims: outreach v1, decided by Mike
Who picked a lead up and when, so nothing sits waiting unnoticed. No
outcomes, no end states, nothing on join_leads changes: the lead stays the
record of what the family said and the claim is only who has it. One row
per claim in lead_claims; the current claim is the latest unreleased.
Taking over is release then claim, never a silent overwrite: 409 names
the holder. The holder or an owner may release. Every claim and release
lands in the action log; the summary carries an unclaimed count.

tests/smoke_admin.py 112 -> 122.
2026-09-04 18:34:01 -04:00
thethreemagi aaa77be14d people management, account self-service, password reset by link
Until now nobody could be invited without a script and a forgotten
password was a locked account. The identity layer already had invites,
sessions and the capability map; this wires the levers to them.

Admin API (people:invite_leader and up; the break-glass token cannot act
here, it has no person): list people with roles, memberships, live
sessions and keys, plus open invites and what YOU may grant; invite with
the URL returned once (no outbound mail yet, hand it over yourself);
revoke an invite; replace roles and memberships in full (owner only);
disable and enable (owner only, never yourself, never the last owner;
disabling ends sessions now and keys die through can()); mint a one-time
24-hour reset link (admin may reset anyone but an owner). Every one logs
who, whom and what.

Site: /account grows details and change-password forms (current password
required; the other devices are signed out, this one stays). /reset/{token}
sets a password, burns the link, ends every session and signs the person
in. Expired, used, revoked and never-existed read the same.

tests/smoke_identity.py 92 -> 123. Driven end to end on a throwaway with
a DB copy: invite, accept, account forms, reset link used then reused
(410), disable (session dies, login 401), enable, roles.
2026-09-04 18:18:39 -04:00
thethreemagi 7287f0b515 seeded events are the site's; every write lands in the action log
calendar_write.owns() now accepts both site namespaces: the 32 events the
2026-08-29 seed stamped site73-<sha1>@greenlanescouts73.org and the site's
own @site73.greenlanescouts73.org. The rule exists to keep the site off
@band.us and off anything a person adds in a calendar client, not off its
own data. Seeded objects are written back at the seeder's object name so
an edit replaces in place; proven on a throwaway against the real store
(edit, still 32 objects, restored byte-for-byte).

auth_events becomes the one action log. Every P2 write - nearby create,
update, deactivate; unit meeting edit; setting change; announcement post
and take-down - now records who, when, and a one-line before -> after for
the fields that changed, alongside the login, key and calendar entries
already there. GET /api/admin/history (history:read, admin and above, not
scopable on a key) reads it newest first with a kind prefix filter and
before= paging; rowid breaks second-resolution ties.

tests/smoke_admin.py 102 -> 112.
2026-09-04 18:10:03 -04:00
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 c458b8e64d client IP is the last X-Forwarded-For hop, not the first
NPM sets the header with $proxy_add_x_forwarded_for, which appends the
connecting address to whatever the client sent. The first hop is therefore
client-controlled and the last is the one NPM vouches for. Reading the
first hop would have let an outsider claim a LAN address with one header,
and admin_api.client_is_lan decides on it. Found while verifying the nginx
block removal; fixed before the block came out was relied on. Also
tightens login throttling, which used the same helper.
2026-09-04 17:48:15 -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 0b337f1f19 meeting words come from the units table; the constants are now defaults
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.
2026-09-04 16:48:06 -04:00
thethreemagi 5c7a8dd785 login: honour next on both exits, same-origin only
The console's 401 redirect carries next=/leaders/ and /login dropped it, so a
leader arriving cold landed on /account. next now rides the form as a hidden
field and is applied after a successful POST and when an already-signed-in
person hits /login. identity.safe_next() admits only a relative path with a
single leading slash - no scheme, no //, no backslash, no control characters -
so the login page cannot become an open redirect. Ten checks added to
tests/smoke_identity.py; the suite is 59 + 24 + 53, all green.
2026-09-04 16:28:58 -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
thethreemagi 8ddcccf293 compose: forward ADMIN_BOOTSTRAP_EMAIL and SITE_BASE_URL to the container
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.
2026-09-04 11:30:33 -04:00
thethreemagi bed93072cb P0: identity layer - units, people, roles, invites, sessions
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.
2026-09-04 11:29:32 -04:00
thethreemagi 985253422d harden: run unprivileged, read-only rootfs, no capabilities
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.
2026-09-01 13:38:47 -04:00
thethreemagi 2d7784bcbd compose: cap container logs at 5x10MB
Bot traffic writes to this log nightly and it had no rotation configured.
2026-09-01 13:37:15 -04:00
thethreemagi 7221981389 compose: bind 8132 to localhost only
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.
2026-09-01 13:35:53 -04:00
thethreemagi 77ad373885 join: honeypot field + server-side validation on POST /join
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.
2026-09-01 13:17:08 -04:00