Files
Claude 8e8e74ac4b whoami returns enough to attribute and scope, and CAPS gains finance
scout-finance validates its session against this API and needs four
things whoami did not return. Without them it reports itself down
rather than degrading, which is correct and also useless.

- id, so a finance row can carry entered_by. The email is
  display-facing and is the wrong thing to write rows against.
- preferred_name / full_name, for entered_by_name, captured at write
  time so a historical report carries the name as of that date.
- global_capabilities, separate from the union. The union answers
  "may they see this screen"; the site-wide set answers "does this
  grant reach a unit they hold no membership in". For an admin who is
  also a den leader those are not the same, and collapsing them lets
  a pack-only grant travel to the troop.
- memberships[].capabilities, so a separate service scopes per unit
  without keeping a second copy of CAPS. Nothing outside this file
  may map a role to a capability.

Built in identity.whoami_payload() rather than in the route, so it is
testable with no HTTP and the capability map stays in one place. An
API key narrows the per-membership sets too, so a key can never appear
to hold what can() would refuse.

CAPS: finance:read and finance:write on leader, because a treasurer is
a leader and leader-wide read is a deliberate design decision in
finance.md. finance:admin on admin only, for categories, accounts and
finance settings, which are site-wide.

The break-glass token path keeps the same shape with a null id and no
memberships. It has no person behind it, so nothing it did could be
attributed; scout-finance refuses it outright.

Additive throughout. scout-control reads none of these fields.
smoke_identity 138, smoke_admin 164, smoke_documents 24.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHy2gB4QvKmRurfXwYbwCB
2026-09-10 07:01:49 -04:00

1119 lines
51 KiB
Python

"""
admin_api.py - read API over the internal record store.
This is the seam the future scout admin panel plugs into. The panel talks HTTP
to these endpoints; it never opens the SQLite file directly. That keeps the
panel deployable anywhere (separate container, separate host) and keeps this
app the only writer to its own database.
Leads are read-only, and that is deliberate. The old PATCH route set
status/assigned_to/notes on a lead - columns that were removed because they
were a guess at an outreach process nobody has designed. When that process
exists it gets its own table and its own write routes; until then there is
nothing on a lead to mutate.
Announcements ARE writable, and are the deliberate exception to that. The
read-only rule exists because a lead has nothing mutable on it. An
announcement is defined by being mutable and expiring - it is posted, it
shows, it comes down. Writing it is the entire feature. The caps that keep
the banner from becoming a mess live in store.py, not here, so the panel and
any future client inherit them rather than reimplementing them.
Auth, two ways, session first.
A signed-in person holding the required capability is allowed. Otherwise the
X-Admin-Token header must match ADMIN_TOKEN, which is retained as BREAK GLASS:
if the identity layer is broken there has to be a way in that does not depend
on the identity layer.
Still FAILS CLOSED. With no session and no ADMIN_TOKEN set, every route returns
503. These endpoints expose parent names, emails and phone numbers for minors'
families, so an unconfigured deployment must not serve them. Adding sessions
widened who may read; it did not soften what happens when nothing is
configured.
Capability, not role, decides. Lead routes need leads:read, announcement routes
need announcements:write, and both are answered by identity.can() against the
one CAPS dictionary - never by a role comparison here.
"""
import hmac
import html as _html
import inspect
import ipaddress
import os
import re
from fastapi import APIRouter, Body, Header, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, PlainTextResponse
import auth
import calendar_write
import identity
import store
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "").strip()
router = APIRouter(prefix="/api/admin", tags=["admin"])
# /api/docs sits beside /api/admin, not under it: it describes the admin API
# and is gated like it, but a docs URL under the admin prefix would be one
# more path the LAN-only NPM block has to reason about.
docs_router = APIRouter(prefix="/api", tags=["docs"])
# What counts as "inside" for the LAN rule. Facts about the network, so
# they live in code; the CHOICE of whether keys are LAN-only is a setting.
# The docker range is here because NPM, the routines and any sibling
# container reach this app from arrstack_arr_net, and a container on that
# network is already inside.
LAN_NETS = [ipaddress.ip_network(n) for n in
("10.0.0.0/24", "10.0.1.0/24", "127.0.0.0/8", "172.16.0.0/12")]
def client_is_lan(request):
"""True if the caller's address is on the LAN. Uses the first
X-Forwarded-For hop, which NPM sets; an unparseable or absent address is
NOT lan - the rule fails closed."""
try:
ip = ipaddress.ip_address(auth._client_ip(request) or "")
except ValueError:
return False
return any(ip in n for n in LAN_NETS)
def _log(request, kind, detail):
"""One line in the action log for a write, attributed to whoever the
session or key resolved to; the break-glass token logs as itself."""
person = _person(request)
identity.log_event(kind, person_id=person["id"] if person else None,
actor_id=person["id"] if person else None,
email=person["email"] if person else "admin-token",
detail=detail, ip=auth._client_ip(request))
def _diff(before, after, fields):
"""'town: "X" -> "Y"; meets: "-" -> "Mondays"' for the fields that changed."""
out = []
for f in fields:
a, b = (before or {}).get(f), (after or {}).get(f)
if a != b:
out.append('%s: %r -> %r' % (f, a if a not in (None, "") else "-", b if b not in (None, "") else "-"))
return "; ".join(out) or "no change"
def _person(request):
"""Who is calling: a session first, then an API key, then nobody.
Keys are honoured ONLY here, on the admin API. The rest of the site
(documents, account) is for people at a screen and reads sessions alone,
so a scoped key never widens into a browser identity."""
p = auth.current_person(request)
if p:
return p
h = request.headers.get("authorization", "")
if h.lower().startswith("bearer "):
return identity.api_key_person(h[7:].strip())
return None
def _auth(request, token, capability, unit_id=None):
"""Authorise, and return a label naming who acted, for created_by.
Order matters: session first, so a normal signed-in leader never depends on
the shared token, and the token stays a fallback rather than the everyday
path.
unit_id scopes a membership capability to one unit: a pack leader holds
unit:write_own, but only within the pack. Global roles pass a scoped check
for every unit, and the break-glass token reaches everything - it exists
for when identity is broken, so it cannot depend on identity's scoping.
"""
person = _person(request)
if person:
if person.get("key_scopes") is not None and not client_is_lan(request) \
and identity.get_setting("api_keys_from") == "lan":
raise HTTPException(403, "API keys may only be used from the LAN right now "
"(site setting api_keys_from)")
if not identity.can(person, capability, unit_id):
raise HTTPException(403, "your account does not have %s" % capability
+ (" for this unit" if unit_id else "")
+ (" (key %s is scoped narrower)" % person["key_prefix"]
if person.get("key_scopes") is not None else ""))
return person["email"]
if not ADMIN_TOKEN:
raise HTTPException(503, "admin API disabled: sign in, or set ADMIN_TOKEN")
if not token or not hmac.compare_digest(token, ADMIN_TOKEN):
raise HTTPException(401, "sign in, or send a valid X-Admin-Token")
# The shared token is break glass for an operator at the console. It is
# never usable from outside, and that is not a setting.
if not client_is_lan(request):
raise HTTPException(403, "the admin token is LAN-only")
return "admin-token"
@router.get("/summary")
def get_summary(request: Request, x_admin_token: str = Header(None)):
_auth(request, x_admin_token, "leads:read")
return store.summary()
@router.get("/leads")
def get_leads(request: Request, since: str = None, q: str = None,
limit: int = Query(100, ge=1, le=500), offset: int = 0,
x_admin_token: str = Header(None)):
_auth(request, x_admin_token, "leads:read")
return {"leads": store.list_leads(since=since, q=q, limit=limit, offset=offset)}
@router.get("/leads/{record_id}")
def get_one(request: Request, record_id: str, x_admin_token: str = Header(None)):
_auth(request, x_admin_token, "leads:read")
rec = store.get_lead(record_id)
if not rec:
raise HTTPException(404, "no such lead")
return rec
@router.post("/leads/{record_id}/claim")
def claim_lead(request: Request, record_id: str, x_admin_token: str = Header(None)):
"""Take a lead so it is not sitting unclaimed. Outreach v1 is exactly
this: who picked it up and when. No outcomes. 409 if someone else has
it - taking over is release, then claim, never a silent overwrite."""
_auth(request, x_admin_token, "leads:read")
person = _person(request)
if not person:
raise HTTPException(403, "claiming needs a signed-in person")
try:
cur = store.claim_lead(record_id, person["id"], person["email"])
except store.ClaimRejected as e:
raise _reject(e)
if not cur:
raise HTTPException(404, "no such lead")
_log(request, "lead.claimed", record_id)
return {"claim": {"email": cur["email"], "claimed_at": cur["claimed_at"]}}
@router.post("/leads/{record_id}/release")
def release_lead(request: Request, record_id: str, x_admin_token: str = Header(None)):
"""Let a lead go. The holder may; so may an owner (people:manage),
which is how a lead gets reassigned when someone steps back."""
_auth(request, x_admin_token, "leads:read")
person = _person(request)
if not person:
raise HTTPException(403, "releasing needs a signed-in person")
try:
cur = store.release_lead(record_id, person["id"], person["email"],
can_release_any=identity.can(person, "people:manage"))
except store.ClaimRejected as e:
raise _reject(e)
if not cur:
raise HTTPException(404, "no such lead")
_log(request, "lead.released", "%s (was %s)" % (record_id, cur["email"]))
return {"claim": None}
@router.get("/mirrors/failed")
def failed(request: Request, target: str = "google_sheet", x_admin_token: str = Header(None)):
_auth(request, x_admin_token, "leads:read")
return {"target": target, "leads": store.failed_mirror_records(target)}
@router.post("/mirrors/retry")
def retry(request: Request, target: str = "google_sheet", x_admin_token: str = Header(None)):
"""Replay leads whose copy to an external target failed. Idempotent-ish:
a lead already marked ok is never retried."""
_auth(request, x_admin_token, "leads:read")
if target != "google_sheet":
raise HTTPException(422, "only google_sheet retry is implemented")
import app as main_app
done, failed_ids = 0, []
for rec in store.failed_mirror_records(target, limit=200):
try:
main_app.sheet_append(rec["payload"])
store.set_mirror(rec["id"], target, True)
done += 1
except Exception as e:
store.set_mirror(rec["id"], target, False, e)
failed_ids.append(rec["id"])
return {"retried_ok": done, "still_failing": failed_ids}
# ----------------------------------------------------------------------------
# Announcements - the one writable object here. See the module docstring.
# ----------------------------------------------------------------------------
def _reject(e):
"""Turn a store guardrail into its HTTP answer, carrying the detail so the
caller is told what to do rather than just refused."""
payload = {"error": e.detail}
payload.update(e.extra)
return HTTPException(e.status, payload)
@router.get("/announcements")
def list_announcements(request: Request, include_expired: bool = False,
limit: int = Query(100, ge=1, le=500),
x_admin_token: str = Header(None)):
"""Every announcement with its computed state: live, scheduled, expired,
revoked, or over_cap. State is returned rather than left to be inferred
from what the homepage happens to render."""
_auth(request, x_admin_token, "announcements:write")
return {"announcements": store.list_announcements(
include_expired=include_expired, limit=limit)}
@router.post("/announcements", status_code=201)
def create_announcement(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)):
"""Post a notice. ends_at is required.
422 if the message is over the character cap or the window is invalid.
409 if the live cap is already reached, listing what is up so you can
decide what to revoke."""
_actor = _auth(request, x_admin_token, "announcements:write")
try:
rec = store.create_announcement(
message=payload.get("message"),
ends_at=payload.get("ends_at"),
starts_at=payload.get("starts_at"),
level=payload.get("level", "info"),
link_url=payload.get("link_url"),
link_text=payload.get("link_text"),
# From the session, never from the body. A caller must not be able
# to attribute a public notice to somebody else. Stored as the
# email rather than the uuid: the column is display-facing, people
# are never hard deleted, and a uuid in a banner audit trail helps
# nobody read it.
created_by=_actor,
)
except store.AnnouncementRejected as e:
raise _reject(e)
_log(request, "announcement.created", "%s %r" % (rec["id"], (rec.get("message") or "")[:60]))
return rec
@router.delete("/announcements/{announcement_id}")
def revoke_announcement(request: Request, announcement_id: str, x_admin_token: str = Header(None)):
"""Take one down early. Sets revoked_at; never deletes the row."""
_auth(request, x_admin_token, "announcements:write")
if not store.get_announcement(announcement_id):
raise HTTPException(404, "no such announcement")
if not store.revoke_announcement(announcement_id):
raise HTTPException(409, "already revoked")
rec = store.get_announcement(announcement_id)
_log(request, "announcement.revoked", "%s %r" % (announcement_id, (rec.get("message") or "")[:60]))
return rec
# ----------------------------------------------------------------------------
# Nearby units - the /find-a-unit directory, and the first writable directory
# data. The verified_at and deactivate-not-delete rules live in store.py so
# any future client inherits them.
# ----------------------------------------------------------------------------
@router.get("/nearby")
def list_nearby(request: Request, include_inactive: bool = False,
x_admin_token: str = Header(None)):
"""The editing view, so deactivated rows are reachable. nearby:write
rather than a read capability: the public page IS the read surface, and
this list exists only to be edited."""
_auth(request, x_admin_token, "nearby:write")
return {"nearby_units": store.list_nearby(include_inactive=include_inactive)}
@router.post("/nearby", status_code=201)
def create_nearby(request: Request, payload: dict = Body(...),
x_admin_token: str = Header(None)):
_auth(request, x_admin_token, "nearby:write")
try:
rec = store.create_nearby(payload)
except store.NearbyRejected as e:
raise _reject(e)
_log(request, "nearby.created", "%s %s %s (%s)" % (rec["id"], rec.get("unit_type"), rec.get("unit_number"), rec.get("town")))
return rec
@router.patch("/nearby/{nearby_id}")
def update_nearby(request: Request, nearby_id: str, payload: dict = Body(...),
x_admin_token: str = Header(None)):
"""Partial update. Saving bumps verified_at to today unless the payload
carries an explicit date - see store.py for why saving is verifying."""
_auth(request, x_admin_token, "nearby:write")
before = store.get_nearby(nearby_id)
try:
rec = store.update_nearby(nearby_id, payload)
except store.NearbyRejected as e:
raise _reject(e)
if not rec:
raise HTTPException(404, "no such nearby unit")
_log(request, "nearby.updated", "%s %s %s: %s" % (nearby_id, rec.get("unit_type"), rec.get("unit_number"),
_diff(before, rec, [k for k in store.NEARBY_FIELDS if k != "verified_at"])))
return rec
@router.delete("/nearby/{nearby_id}")
def deactivate_nearby(request: Request, nearby_id: str, x_admin_token: str = Header(None)):
"""Take a unit off the page. Sets active=0; never deletes the row."""
_auth(request, x_admin_token, "nearby:write")
if not store.get_nearby(nearby_id):
raise HTTPException(404, "no such nearby unit")
if not store.deactivate_nearby(nearby_id):
raise HTTPException(409, "already inactive")
rec = store.get_nearby(nearby_id)
_log(request, "nearby.deactivated", "%s %s %s" % (nearby_id, rec.get("unit_type"), rec.get("unit_number")))
return rec
# ----------------------------------------------------------------------------
# Our own units - meeting time and place only. unit:write_own is checked
# against the unit in the URL, so a pack leader edits the pack and not the
# troop; admins hold it globally and reach both.
# ----------------------------------------------------------------------------
@router.get("/units")
def list_units(request: Request, x_admin_token: str = Header(None)):
"""The units the caller may edit, which is what the screen this feeds
shows. A pack leader gets the pack; a global role or the break-glass
token gets everything. The answer IS the scope - no second filter for
the console to get wrong."""
_auth(request, x_admin_token, "unit:write_own")
units = identity.list_units(include_inactive=True)
person = auth.current_person(request)
if person:
units = [u for u in units if identity.can(person, "unit:write_own", u["id"])]
return {"units": units}
@router.patch("/units/{slug_or_id}")
def update_unit(request: Request, slug_or_id: str, payload: dict = Body(...),
x_admin_token: str = Header(None)):
unit = identity.get_unit(slug_or_id)
# Scope to the unit when it exists; an unknown slug still goes through
# _auth first, so probing paths answers 401/403 before it answers 404.
_auth(request, x_admin_token, "unit:write_own",
unit_id=unit["id"] if unit else None)
try:
rec = identity.update_unit_meets(slug_or_id, payload)
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
_log(request, "unit.updated", "%s: %s" % (rec.get("slug"), _diff(unit, rec, list(identity.UNIT_MEETS_FIELDS))))
return rec
# ----------------------------------------------------------------------------
# Site settings - the typed key registry lives in identity.SETTINGS_KEYS;
# unknown keys are rejected there, not silently stored.
# ----------------------------------------------------------------------------
@router.get("/settings")
def list_settings(request: Request, x_admin_token: str = Header(None)):
_auth(request, x_admin_token, "settings:write")
return {"settings": identity.all_settings()}
@router.put("/settings/{key}")
def put_setting(request: Request, key: str, payload: dict = Body(...),
x_admin_token: str = Header(None)):
"""Set one value, or clear it back to the code default with value: null."""
actor = _auth(request, x_admin_token, "settings:write")
before = identity.get_setting(key) if key in identity.SETTINGS_KEYS else None
try:
rec = identity.set_setting(key, payload.get("value"), actor=actor)
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
after = identity.get_setting(key)
_log(request, "setting.updated", "%s: %r -> %r%s" % (key, before, after,
" (cleared to default)" if payload.get("value") is None else ""))
return rec
# ----------------------------------------------------------------------------
# API keys (P3). Own keys only - apikeys:own. A key cannot reach these routes
# (apikeys:own is unscopable), and the break-glass token has no person to
# own a key, so both fall out naturally rather than by special case.
# ----------------------------------------------------------------------------
def _key_owner(request, token):
_auth(request, token, "apikeys:own")
person = _person(request)
if not person:
raise HTTPException(403, "keys belong to a signed-in person; the admin token cannot own one")
return person
@router.get("/keys")
def list_keys(request: Request, x_admin_token: str = Header(None)):
"""Your keys, newest first, with state (active / expired / revoked) and
the scopes you may put on a new one. Key secrets are never returned."""
person = _key_owner(request, x_admin_token)
return {"keys": identity.list_api_keys(person["id"]),
"scopable": identity.scopable_caps(person),
"max_days": identity.KEY_MAX_DAYS}
@router.post("/keys", status_code=201)
def create_key(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)):
"""Mint a key. Body: label, scopes (list), expires_days (optional, 1-365).
The response carries `key` ONCE; it is not stored and cannot be shown again.
Send it as `Authorization: Bearer gls73_...` to /api/admin routes."""
person = _key_owner(request, x_admin_token)
try:
full, row = identity.mint_api_key(person, payload.get("label"), payload.get("scopes"),
payload.get("expires_days"))
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
row["key"] = full
return row
@router.delete("/keys/{key_id}")
def revoke_key(request: Request, key_id: str, x_admin_token: str = Header(None)):
"""Revoke one of your keys. The row stays. A key that is not yours is
404, never 403 - an id is not confirmed to exist."""
person = _key_owner(request, x_admin_token)
try:
row = identity.revoke_api_key(person, key_id)
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
if not row:
raise HTTPException(404, "no such key")
return row
@router.get("/whoami")
def whoami(request: Request, x_admin_token: str = Header(None)):
"""Who the API thinks you are and what you can do - the first thing to
call with a new key."""
person = _person(request)
if person:
if person.get("key_scopes") is not None and not client_is_lan(request) \
and identity.get_setting("api_keys_from") == "lan":
raise HTTPException(403, "API keys may only be used from the LAN right now "
"(site setting api_keys_from)")
via = ("key " + person["key_prefix"]) if person.get("key_scopes") is not None else "session"
return identity.whoami_payload(person, lan=client_is_lan(request), via=via)
if not ADMIN_TOKEN:
raise HTTPException(503, "admin API disabled: sign in, or set ADMIN_TOKEN")
if x_admin_token and hmac.compare_digest(x_admin_token, ADMIN_TOKEN):
if not client_is_lan(request):
raise HTTPException(403, "the admin token is LAN-only")
# Break glass has no person behind it, so id is null and there are no
# memberships. A caller that needs to attribute a write must refuse
# this, and scout-finance does.
return {"id": None, "email": None, "via": "admin-token", "capabilities": ["*"],
"global_capabilities": ["*"], "memberships": [], "lan": True}
raise HTTPException(401, "sign in, send a Bearer key, or a valid X-Admin-Token")
# ----------------------------------------------------------------------------
# /api/docs - generated from this router, never hand-written. Path, methods
# and docstring come from FastAPI's route objects; the capability is read
# out of each handler's own _auth() call, so it cannot drift from the check.
# ----------------------------------------------------------------------------
_CAP_RE = re.compile(r"""_(?:auth|people_actor)\([^)]*?["']([a-z_]+:[a-z_]+)["']""")
@docs_router.get("/docs/approved", response_class=PlainTextResponse)
def api_docs_approved(request: Request):
"""API.md: the approved reference, generated from this registry plus a
full HTTP drive of every route (tests/http_drive.py). Markdown; the
console renders it at /leaders/api. Same gate as /api/docs."""
_auth(request, None, "api:docs")
import pathlib
p = pathlib.Path(__file__).with_name("API.md")
if not p.exists():
raise HTTPException(404, "API.md is not in this build")
return p.read_text()
def route_capability(endpoint):
try:
src = inspect.getsource(endpoint)
except (OSError, TypeError):
return None
m = _CAP_RE.search(src)
if m:
return m.group(1)
if "_key_owner(" in src:
return "apikeys:own"
if "_roster_actor(" in src:
return "roster:write"
return None
def describe_routes():
"""The registry, as data. Also what the smoke test checks."""
out = []
for r in router.routes:
methods = sorted(m for m in getattr(r, "methods", []) or [] if m not in ("HEAD", "OPTIONS"))
if not methods:
continue
params = [p.name for p in inspect.signature(r.endpoint).parameters.values()
if p.name not in ("request", "x_admin_token", "payload")]
out.append({"path": r.path, "methods": methods,
"capability": route_capability(r.endpoint),
"params": params,
"doc": inspect.getdoc(r.endpoint) or ""})
return sorted(out, key=lambda d: (d["path"], d["methods"]))
@docs_router.get("/docs", response_class=HTMLResponse)
def api_docs(request: Request, x_admin_token: str = Header(None)):
"""This page. Leader and above."""
_auth(request, x_admin_token, "api:docs")
person = _person(request)
e = _html.escape
rows = "".join(
"<tr><td><code>%s</code></td><td><code>%s</code></td><td><code>%s</code></td>"
"<td>%s</td><td>%s</td></tr>"
% (e(" ".join(d["methods"])), e(d["path"]), e(d["capability"] or "-"),
e(", ".join(d["params"])) or "-", e(d["doc"]).replace("\n", "<br>"))
for d in describe_routes())
mine = ""
if person:
mine = ("<p>You are <code>%s</code>%s with: <code>%s</code>.</p>"
% (e(person["email"]),
" via key <code>%s</code>" % e(person["key_prefix"]) if person.get("key_scopes") is not None else "",
e(", ".join(person["capabilities"]))))
body = ("<!doctype html><html><head><meta charset=utf-8><meta name=robots content=noindex>"
"<meta name=viewport content=\"width=device-width,initial-scale=1\">"
"<title>greenlanescouts73.org admin API</title><style>"
"body{font:15px/1.5 system-ui,sans-serif;margin:0;padding:24px;color:#1c2430;background:#f4f5f7}"
"table{border-collapse:collapse;background:#fff;width:100%%}th,td{text-align:left;vertical-align:top;"
"padding:8px 10px;border-top:1px solid #eef0f3;font-size:14px}th{font-size:12.5px;color:#5b6472}"
"code{font-size:13px}h1{font-size:20px}.n{max-width:900px}</style></head><body><div class=n>"
"<h1>greenlanescouts73.org admin API</h1>"
"<p>Generated from the route registry on every request; there is no hand-written copy to drift.</p>"
"<p><b>Auth</b>, tried in this order: the site session cookie; "
"<code>Authorization: Bearer gls73_...</code> (an API key, scoped to the capabilities its owner chose - "
"mint one at <a href=\"/leaders/keys\">/leaders/keys</a>); <code>X-Admin-Token</code> (break glass, "
"operators only). A signed-in caller without the capability gets 403; an anonymous one gets 401. "
"Bodies are JSON. Start with <code>GET /api/admin/whoami</code>.</p>%s"
"<table><tr><th>Method</th><th>Path</th><th>Needs</th><th>Query / path params</th><th>Notes</th></tr>%s</table>"
"</div></body></html>" % (mine, rows))
return HTMLResponse(body)
# ----------------------------------------------------------------------------
# Calendar write-back (P4). The site becomes a second writer to Radicale,
# owning only UIDs ending calendar_write.UID_SUFFIX. Reads come from the
# scout-calendar feed (fetched fresh here, not from the page cache), so the
# list is the same rows the public site renders plus uid and ownership.
# ----------------------------------------------------------------------------
def _feed_rows():
import app as main_app
try:
rows = main_app._fetch_feed()
except Exception as e:
raise HTTPException(502, "calendar feed unreachable: %s" % e)
for r in rows:
r["mine"] = calendar_write.owns(r.get("uid")) and not r.get("recurring")
return rows
@router.get("/calendar")
def list_calendar(request: Request, x_admin_token: str = Header(None)):
"""Every event the public calendar shows, newest first is NOT the order:
the feed's own date order. `mine` marks rows the site created and may
edit or delete; everything else is read-only here and edited in a
CalDAV client. `configured` says whether writes are possible at all."""
_auth(request, x_admin_token, "calendar:write")
return {"events": _feed_rows(), "configured": calendar_write.configured(),
"uid_suffix": calendar_write.UID_SUFFIX}
@router.post("/calendar", status_code=201)
def create_event(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)):
"""Add an event. Body: title, date (YYYY-MM-DD), unit (pack|troop|both),
optional end, time (HH:MM 24h), end_time, location, badge, description.
No time = all-day. A timed one-day event with no end_time lasts 90 min."""
_auth(request, x_admin_token, "calendar:write")
try:
ev = calendar_write.clean(payload)
uid = calendar_write.new_uid()
calendar_write.put_event(uid, ev)
except calendar_write.CalendarRejected as e:
raise HTTPException(e.status, e.detail)
_log(request, "calendar.created", "%s %s %s" % (uid, ev["date"].isoformat(), ev["title"]))
_bust_site_cache()
return {"uid": uid, "event": _serial(ev)}
@router.put("/calendar/{uid}")
def replace_event(request: Request, uid: str, payload: dict = Body(...), x_admin_token: str = Header(None)):
"""Replace one site-owned event in full (same body as POST). A UID the
site does not own is 403 before anything is sent to the store."""
_auth(request, x_admin_token, "calendar:write")
if not calendar_write.owns(uid):
raise HTTPException(403, "the site only manages its own events")
try:
ev = calendar_write.clean(payload)
calendar_write.put_event(uid, ev)
except calendar_write.CalendarRejected as e:
raise HTTPException(e.status, e.detail)
_log(request, "calendar.updated", "%s %s %s" % (uid, ev["date"].isoformat(), ev["title"]))
_bust_site_cache()
return {"uid": uid, "event": _serial(ev)}
@router.delete("/calendar/{uid}")
def delete_event(request: Request, uid: str, x_admin_token: str = Header(None)):
"""Remove one site-owned event from the store. Unlike everything else on
this API this really deletes: the calendar's history is Radicale's git
log, not a revoked_at column."""
_auth(request, x_admin_token, "calendar:write")
if not calendar_write.owns(uid):
raise HTTPException(403, "the site only manages its own events")
try:
status = calendar_write.delete_event(uid)
except calendar_write.CalendarRejected as e:
raise HTTPException(e.status, e.detail)
if status == 404:
raise HTTPException(404, "no such event in the store")
_log(request, "calendar.deleted", uid)
_bust_site_cache()
return {"uid": uid, "deleted": True}
def _serial(ev):
out = dict(ev)
out["date"] = ev["date"].isoformat()
out["end"] = ev["end"].isoformat() if ev["end"] else None
return out
def _bust_site_cache():
"""The public pages cache the feed for five minutes; a leader who just
posted an event should not wait that long to see it. The feed itself
refreshes within a minute."""
try:
import app as main_app
main_app._feed_state["fetched"] = 0.0
except Exception:
pass
# ----------------------------------------------------------------------------
# History - the action log, read side. Admin and above: it carries emails
# and IPs from logins alongside the content writes.
# ----------------------------------------------------------------------------
@router.get("/history")
def get_history(request: Request, limit: int = Query(200, ge=1, le=500), kind: str = None,
before: str = None, x_admin_token: str = Header(None)):
"""Newest first. `kind` is a prefix filter (calendar., nearby., login.).
`before` is an ISO stamp for paging. Every write on this API since
2026-09-04 lands here with who did it and a one-line diff."""
_auth(request, x_admin_token, "history:read")
return {"events": identity.list_events(limit=limit, kind_prefix=kind, before=before)}
# ----------------------------------------------------------------------------
# People. Invite, roles, disable/enable, password reset links. The rules
# (never zero owners, never disable yourself, grant only what you hold) live
# in identity.py; these routes only translate to HTTP. A session or key is
# required: the break-glass token has no person to act as.
# ----------------------------------------------------------------------------
def _people_actor(request, token, capability):
_auth(request, token, capability)
person = _person(request)
if not person:
raise HTTPException(403, "people management needs a signed-in person; the admin token cannot act here")
return person
@router.get("/people")
def list_people(request: Request, x_admin_token: str = Header(None)):
"""Everyone with an account, their roles and memberships, active
sessions and keys; plus open invites; plus what YOU may grant."""
actor = _people_actor(request, x_admin_token, "people:invite_leader")
return {"people": identity.list_people(), "invites": identity.list_open_invites(),
"units": identity.list_units(include_inactive=True),
"grantable": identity.grantable_roles(actor), "me": actor["id"]}
@router.post("/people/invite", status_code=201)
def invite(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)):
"""Mint an invite. Body: email, global_role (optional), units
([{unit_id, role, title}]). Returns the invite URL ONCE; there is no
outbound mail yet, so hand it over yourself. Reissuing for the same
address revokes the earlier link. 14-day expiry."""
actor = _people_actor(request, x_admin_token, "people:invite_leader")
try:
row, url = identity.invite_person(actor, payload.get("email"), payload.get("global_role") or None,
payload.get("units") or [])
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
row.pop("token_hash", None)
row["url"] = url
return row
@router.delete("/people/invite/{invite_id}")
def revoke_invite(request: Request, invite_id: str, x_admin_token: str = Header(None)):
actor = _people_actor(request, x_admin_token, "people:invite_leader")
row = identity.revoke_invite(actor, invite_id)
if not row:
raise HTTPException(404, "no such open invite")
row.pop("token_hash", None)
return row
@router.put("/people/{person_id}/roles")
def put_roles(request: Request, person_id: str, payload: dict = Body(...), x_admin_token: str = Header(None)):
"""Replace a person's global role and unit memberships. Body:
global_role (owner|admin|null), units ([{unit_id, role, title}], the full
list). Owner only. Refuses to leave zero owners."""
actor = _people_actor(request, x_admin_token, "people:manage")
try:
rec = identity.set_roles(actor, person_id, payload.get("global_role") or None, payload.get("units") or [])
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
if not rec:
raise HTTPException(404, "no such person")
return rec
@router.post("/people/{person_id}/disable")
def disable(request: Request, person_id: str, payload: dict = Body(None), x_admin_token: str = Header(None)):
"""Disable a person now: sessions end, keys stop, documents close. The
row stays. Owner only; not yourself; not the last owner."""
actor = _people_actor(request, x_admin_token, "people:manage")
try:
rec = identity.disable_person(actor, person_id, (payload or {}).get("reason"))
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
if not rec:
raise HTTPException(404, "no such person")
return rec
@router.post("/people/{person_id}/enable")
def enable(request: Request, person_id: str, x_admin_token: str = Header(None)):
actor = _people_actor(request, x_admin_token, "people:manage")
try:
rec = identity.enable_person(actor, person_id)
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
if not rec:
raise HTTPException(404, "no such person")
return rec
@router.post("/people/{person_id}/reset", status_code=201)
def reset_link(request: Request, person_id: str, x_admin_token: str = Header(None)):
"""Mint a one-time password-reset link (24 h), returned ONCE. Their
current password keeps working until the link is used; using it ends
every session they have. Admin may reset anyone but an owner."""
actor = _people_actor(request, x_admin_token, "people:invite_admin")
try:
url = identity.create_reset(actor, person_id)
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
if not url:
raise HTTPException(404, "no such person")
return {"url": url, "expires_hours": identity.RESET_TTL_HOURS}
# ----------------------------------------------------------------------------
# Roster (2026-09-04). roster:write, never on a script key. The program
# year is the site's PROGRAM_YEAR unless the caller names one.
# ----------------------------------------------------------------------------
def _year(year):
if year:
return year
import app as main_app
return getattr(main_app, "PROGRAM_YEAR", "2026-2027")
def _roster_actor(request, token):
_auth(request, token, "roster:write")
person = _person(request)
if not person:
raise HTTPException(403, "the roster needs a signed-in person")
return person
@router.get("/roster")
def get_roster(request: Request, unit: str = None, year: str = None, include_inactive: bool = False,
x_admin_token: str = Header(None)):
"""Households with their scouts and this year's checklist. `unit` is a
slug or id. Health forms are never stored: a check says one was
collected, by whom and when, and nothing else."""
_roster_actor(request, x_admin_token)
unit_id = None
if unit:
u = identity.get_unit(unit)
if not u:
raise HTTPException(404, "no such unit")
unit_id = u["id"]
y = _year(year)
return {"year": y, "items": list(store.ROSTER_ITEMS), "item_words": store.ROSTER_ITEM_WORDS,
"units": identity.list_units(), "households": store.list_roster(y, unit_id, include_inactive)}
@router.get("/roster/households/{hid}")
def get_household(request: Request, hid: str, year: str = None, x_admin_token: str = Header(None)):
_roster_actor(request, x_admin_token)
h = store.get_household(hid, _year(year))
if not h:
raise HTTPException(404, "no such household")
h["people"] = store.household_people(hid)
return h
@router.put("/roster/households/{hid}/people")
def put_household_people(request: Request, hid: str, payload: dict = Body(...), x_admin_token: str = Header(None)):
"""Which accounts belong to this family: body {person_ids: [...]}. This is
what a parent's Family page is built from. Admin and above."""
_auth(request, x_admin_token, "people:invite_leader")
if not _person(request):
raise HTTPException(403, "needs a signed-in person")
ids = [str(p) for p in payload.get("person_ids") or []]
known = {p["id"] for p in identity.list_people()}
bad = [p for p in ids if p not in known]
if bad:
raise HTTPException(422, "unknown person ids: %s" % ", ".join(bad))
res = store.set_household_people(hid, ids)
if res is None:
raise HTTPException(404, "no such household")
_log(request, "roster.household_people", "%s -> %d account(s)" % (hid, len(res)))
return {"person_ids": res}
@router.get("/family")
def my_family(request: Request, year: str = None, x_admin_token: str = Header(None)):
"""The signed-in person's own families: scouts, dens, and this year's
checklist as seen from the family's side. account:self, so a parent with
a member account gets exactly their own and nothing else."""
_auth(request, x_admin_token, "account:self")
person = _person(request)
if not person:
raise HTTPException(403, "needs a signed-in person")
y = _year(year)
return {"year": y, "items": list(store.ROSTER_ITEMS), "item_words": store.ROSTER_ITEM_WORDS,
"households": store.households_for_person(person["id"], y),
"me": {"email": person["email"], "name": person.get("preferred_name") or person.get("full_name")}}
@router.post("/roster/households", status_code=201)
def create_household(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)):
"""A family. Body: parent_name (required), email, phone, second_parent,
notes. Or `lead_id` alone to import a join lead as the family - the lead
is copied and linked, never changed."""
actor = _roster_actor(request, x_admin_token)
try:
if payload.get("lead_id"):
hid = store.import_lead(payload["lead_id"], created_by=actor["email"])
if not hid:
raise HTTPException(404, "no such lead")
_log(request, "roster.imported", "%s from lead %s" % (hid, payload["lead_id"]))
else:
hid = store.create_household(payload, created_by=actor["email"])
_log(request, "roster.household_created", "%s %s" % (hid, payload.get("parent_name")))
except store.RosterRejected as e:
raise _reject(e)
return store.get_household(hid, _year(None))
@router.patch("/roster/households/{hid}")
def patch_household(request: Request, hid: str, payload: dict = Body(...), x_admin_token: str = Header(None)):
_roster_actor(request, x_admin_token)
before = store.get_household(hid, _year(None))
try:
rec = store.update_household(hid, payload)
except store.RosterRejected as e:
raise _reject(e)
if not rec:
raise HTTPException(404, "no such household")
_log(request, "roster.household_updated", "%s: %s" % (hid, _diff(before, rec, list(store.HOUSEHOLD_FIELDS) + ["active"])))
return store.get_household(hid, _year(None))
@router.post("/roster/households/{hid}/scouts", status_code=201)
def add_scout(request: Request, hid: str, payload: dict = Body(...), x_admin_token: str = Header(None)):
"""Body: first_name (required), last_name, unit_id (required), den, bsa_member_id."""
_roster_actor(request, x_admin_token)
try:
rec = store.add_scout(hid, payload)
except store.RosterRejected as e:
raise _reject(e)
if not rec:
raise HTTPException(404, "no such household")
_log(request, "roster.scout_added", "%s %s (%s)" % (rec["id"], rec["first_name"], rec.get("den") or "-"))
return rec
@router.patch("/roster/scouts/{sid}")
def patch_scout(request: Request, sid: str, payload: dict = Body(...), x_admin_token: str = Header(None)):
_roster_actor(request, x_admin_token)
try:
res = store.update_scout(sid, payload)
except store.RosterRejected as e:
raise _reject(e)
if not res:
raise HTTPException(404, "no such scout")
before, after = res
_log(request, "roster.scout_updated", "%s %s: %s" % (sid, after["first_name"],
_diff(before, after, list(store.SCOUT_FIELDS) + ["active"])))
return after
@router.put("/roster/scouts/{sid}/checks/{item}")
def put_check(request: Request, sid: str, item: str, payload: dict = Body(...), year: str = None,
x_admin_token: str = Header(None)):
"""Mark an item collected for this year: body {done: true|false, note}.
Records who and when. `dues` and `health_form` today."""
actor = _roster_actor(request, x_admin_token)
try:
rec = store.set_check(sid, _year(year), item, bool(payload.get("done")), done_by=actor["email"],
note=payload.get("note"))
except store.RosterRejected as e:
raise _reject(e)
if not rec:
raise HTTPException(404, "no such scout")
_log(request, "roster.check", "%s %s %s -> %s" % (sid, _year(year), item, "done" if payload.get("done") else "cleared"))
return rec
# ----------------------------------------------------------------------------
# Facebook posts (open item 12). The publisher reports; leaders read and
# cancel. Ingest is fbposts:ingest - the scope a script key carries -
# and the image is hash-verified on arrival. The image route runs the same
# capability check as the page, on every request, per the design note:
# a gated page whose images are served unchecked is the failure to avoid.
# ----------------------------------------------------------------------------
@router.get("/fbposts")
def list_fb_posts(request: Request, include_done: bool = True, limit: int = Query(100, ge=1, le=500),
x_admin_token: str = Header(None)):
"""Every post the publisher has reported, newest scheduled first, with
status (drafted, scheduled, handed_off, cancelled, published, failed)."""
_auth(request, x_admin_token, "fbposts:read")
return {"posts": store.list_fb_posts(limit=limit, include_done=include_done)}
@router.post("/fbposts/ingest")
def ingest_fb_post(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)):
"""scout-publisher reports a post. Body: id (<unit>/<queue-stem>), unit,
status, page_id, fb_post_id, message, link, scheduled_for, queue_file,
cancel_url, image_ref, and optionally image {b64, mime, sha256}. The
sha256 is recomputed here; a mismatch is refused."""
_auth(request, x_admin_token, "fbposts:ingest")
image = None
img = payload.get("image")
if img and img.get("b64"):
import base64 as _b64
try:
data = _b64.b64decode(img["b64"], validate=True)
except Exception:
raise HTTPException(422, "image.b64 is not valid base64")
image = (data, img.get("mime") or "", img.get("sha256") or "")
try:
rec = store.upsert_fb_post(payload, image)
except store.FbRejected as e:
raise _reject(e)
_log(request, "fbpost.reported", "%s %s%s" % (rec["id"], rec["status"], " +image" if image else ""))
return rec
@router.get("/fbposts/{pid:path}/image")
def fb_post_image(request: Request, pid: str, x_admin_token: str = Header(None)):
"""The stored image, behind the same gate as the list."""
from fastapi.responses import FileResponse
_auth(request, x_admin_token, "fbposts:read")
rec = store.get_fb_post(pid)
path = store.image_path(rec)
if not path:
raise HTTPException(404, "no image")
return FileResponse(str(path), media_type=rec["image_mime"], headers={"Cache-Control": "private, max-age=86400, immutable"})
@router.post("/fbposts/{pid:path}/cancel")
def cancel_fb_post(request: Request, pid: str, x_admin_token: str = Header(None)):
"""Cancel a scheduled post through the publisher's own signed link, then
record it here. The site never holds the publisher's secret."""
actor = _auth(request, x_admin_token, "fbposts:read")
rec = store.get_fb_post(pid)
if not rec:
raise HTTPException(404, "no such post")
if rec["state"] != "scheduled":
raise HTTPException(409, "post is %s, not scheduled" % rec["state"]
+ (" (its publish time has passed)" if rec["status"] == "scheduled" else ""))
if not rec.get("cancel_url"):
raise HTTPException(409, "no cancel link was recorded for this post")
import urllib.request as _ur, urllib.error as _ue
try:
with _ur.urlopen(_ur.Request(rec["cancel_url"], headers={"User-Agent": "scout-website"}), timeout=15) as resp:
status = resp.status
except _ue.HTTPError as e:
raise HTTPException(502, "publisher answered %d on cancel" % e.code)
except Exception as e:
raise HTTPException(502, "publisher unreachable: %s" % e)
if status >= 300:
raise HTTPException(502, "publisher answered %d on cancel" % status)
out = store.mark_fb_cancelled(pid, actor)
_log(request, "fbpost.cancelled", pid)
return out
@router.post("/fbposts/{pid:path}/reschedule")
def reschedule_fb_post(request: Request, pid: str, payload: dict = Body(...), x_admin_token: str = Header(None)):
"""Edit and repost a scheduled post: body {message, scheduled_for}. The
site asks the publisher's control server, signed with the same per-post
signature as the cancel link, to delete the post on Facebook and redraft
the queue file; the publisher schedules it again on its next cycle
(within about 15 minutes) and reports the new id. Until then the row is
drafted with no cancel link."""
actor = _auth(request, x_admin_token, "fbposts:read")
rec = store.get_fb_post(pid)
if not rec:
raise HTTPException(404, "no such post")
if rec["state"] != "scheduled":
raise HTTPException(409, "post is %s, not scheduled" % rec["state"])
if not rec.get("cancel_url") or not rec.get("fb_post_id"):
raise HTTPException(409, "no signed link was recorded for this post")
message = (payload.get("message") or "").strip()
if not message:
raise HTTPException(422, "message is required")
if len(message) > 5000:
raise HTTPException(422, "message is over 5000 characters")
when = (payload.get("scheduled_for") or "").strip() or None
if when:
try:
import datetime as _dt
d = _dt.datetime.fromisoformat(when.replace("Z", "+00:00"))
if d.tzinfo is None:
raise ValueError
if d <= _dt.datetime.now(_dt.timezone.utc) + _dt.timedelta(minutes=15):
raise HTTPException(422, "scheduled_for must be at least 15 minutes from now")
except ValueError:
raise HTTPException(422, "scheduled_for must be an ISO timestamp with an offset")
import urllib.parse as _up, urllib.request as _ur, urllib.error as _ue, json as _json
u = _up.urlparse(rec["cancel_url"]); q = _up.parse_qs(u.query)
sig = (q.get("sig") or [""])[0]
if not sig:
raise HTTPException(409, "the recorded cancel link carries no signature")
target = _up.urlunparse((u.scheme, u.netloc, "/reschedule", "", "", ""))
body = _json.dumps({"id": rec["fb_post_id"], "sig": sig, "message": message, "publish_at": when}).encode()
try:
with _ur.urlopen(_ur.Request(target, data=body, method="POST",
headers={"Content-Type": "application/json", "User-Agent": "scout-website"}), timeout=60) as resp:
status = resp.status
except _ue.HTTPError as e:
raise HTTPException(502, "publisher answered %d on reschedule" % e.code)
except Exception as e:
raise HTTPException(502, "publisher unreachable: %s" % e)
if status >= 300:
raise HTTPException(502, "publisher answered %d on reschedule" % status)
out = store.upsert_fb_post({"id": pid, "status": "drafted", "message": message, "scheduled_for": when,
"fb_post_id": None, "cancel_url": None, "unit": rec["unit"], "page_id": rec.get("page_id"),
"image_ref": rec.get("image_ref"), "queue_file": rec.get("queue_file"), "link": rec.get("link")})
_log(request, "fbpost.redrafted", "%s by %s%s" % (pid, actor, (" for " + when) if when else ""))
return out