123 checks across all 48 routes plus /api/docs, driven on a throwaway copy of the live database with anonymous, member, leader, admin and owner access; 0 failures. Calendar checks used 2036 probes against the real store and left it at 32 objects. API.md records, per route, the capability and what each request actually returned. tests/api_drive.py is the harness and tests/api_doc.py regenerates the document from its results, so the next approval run is a rerun, not a rewrite.
12 KiB
Green Lane Scouts 73: Admin API
Tested and approved 2026-09-05. Every route below was driven over HTTP on a throwaway copy of the
live database with four levels of access (anonymous, member, unit leader, admin, plus the site owner
where a route is owner-only): 123 checks, 0 failures. The calendar checks ran against the real store
with 2036 probes and left it as found. The live registry is GET /api/docs; this file records what
each route actually did when asked. Regenerate with tests/api_drive.py then tests/api_doc.py.
Base: https://greenlanescouts73.org. Auth: a browser session (cookie s73_session) or a
bearer API key (Authorization: Bearer gls73_...), honoured on /api/admin only. 401 means no
credential; 403 means a credential without the capability. Error bodies are {"detail": ...} or,
from a store guardrail, {"detail": {"error": ...}}. Every write lands in the action log with who
did it and what changed. Times are ISO 8601 with an offset.
Identity
Who is calling, and the live route registry.
GET /api/admin/whoami
Any credential; answers who you are, how you authenticated, and your capabilities.
| Tried | HTTP |
|---|---|
| anon | 401 |
| claude session | 200 |
| member session | 200 |
| admin token from LAN | 200 |
| admin token from WAN | 403 |
| disabled person's session is dead | 401 |
| fresh member session after enable | 200 |
GET /api/docs
Capability api:docs, leader and above. Generated from the router on every request.
| Tried | HTTP |
|---|---|
| anon | 401 |
| member (no api:docs) | 403 |
| leader | 200 |
Summary and leads
Join-form leads are read-only. A claim is who has a lead and when; it is the only thing that changes.
GET /api/admin/summary
Capability leads:read.
| Tried | HTTP |
|---|---|
| anon | 401 |
| member | 403 |
| leader | 200 |
GET /api/admin/leads
Capability leads:read.
| Tried | HTTP |
|---|---|
| leader | 200 |
| search miss | 200 |
| bearer key works | 200 |
| bearer key from WAN while lan | 403 |
| revoked key dead | 401 |
GET /api/admin/leads/{id}
Capability leads:read.
| Tried | HTTP |
|---|---|
| one lead | 200 |
| unknown lead | 404 |
POST /api/admin/leads/{id}/claim
Capability leads:read. Take a lead so it is not sitting unclaimed. Outreach v1 is exactly
| Tried | HTTP |
|---|---|
| claim | 200 |
| claim again (self) | 409 |
| claim by another | 409 |
POST /api/admin/leads/{id}/release
Capability leads:read. Let a lead go. The holder may; so may an owner (people:manage),
| Tried | HTTP |
|---|---|
| release by another admin (no people:manage) | 403 |
| release by owner | 200 |
| release when nobody has it | 409 |
GET /api/admin/mirrors/failed
Capability leads:read.
| Tried | HTTP |
|---|---|
| failed mirrors | 200 |
POST /api/admin/mirrors/retry
Capability leads:read. Replay leads whose copy to an external target failed. Idempotent-ish:
| Tried | HTTP |
|---|---|
| retry sheet (nothing owed) | 200 |
| retry ntfy (not implemented) | 422 |
Announcements
Site banners. ends_at is required. Revoke keeps the row.
GET /api/admin/announcements
Capability announcements:write. Every announcement with its computed state: live, scheduled, expired,
| Tried | HTTP |
|---|---|
| list | 200 |
POST /api/admin/announcements
Capability announcements:write. Post a notice. ends_at is required.
| Tried | HTTP |
|---|---|
| no ends_at | 422 |
| create (2036) | 201 |
| member cannot | 403 |
DELETE /api/admin/announcements/{id}
Capability announcements:write. Take one down early. Sets revoked_at; never deletes the row.
| Tried | HTTP |
|---|---|
| revoke | 200 |
| revoke again | 409 |
| revoke unknown | 404 |
Nearby units
The find-a-unit directory. Deactivate, never delete. Saving bumps verified_at.
GET /api/admin/nearby
Capability nearby:write. The editing view, so deactivated rows are reachable. nearby:write
| Tried | HTTP |
|---|---|
| list | 200 |
| bearer key out of scope | 403 |
POST /api/admin/nearby
Capability nearby:write.
| Tried | HTTP |
|---|---|
| bad unit_type | 422 |
| create | 201 |
PATCH /api/admin/nearby/{id}
Capability nearby:write. Partial update. Saving bumps verified_at to today unless the payload
| Tried | HTTP |
|---|---|
| update | 200 |
| unknown field rejected | 422 |
| unknown | 404 |
DELETE /api/admin/nearby/{id}
Capability nearby:write. Take a unit off the page. Sets active=0; never deletes the row.
| Tried | HTTP |
|---|---|
| deactivate | 200 |
| deactivate again | 409 |
Our units
Meeting day, time and place. A unit leader edits their own unit only.
GET /api/admin/units
Capability unit:write_own. The units the caller may edit, which is what the screen this feeds
| Tried | HTTP |
|---|---|
| pack leader sees only the pack | 200 |
| admin sees both | 200 |
PATCH /api/admin/units/{slug}
Capability unit:write_own.
| Tried | HTTP |
|---|---|
| pack leader cannot edit the troop | 403 |
| bad time | 422 |
| no-change save | 200 |
| unknown | 404 |
Settings
Typed keys only; value: null clears to the code default.
GET /api/admin/settings
Capability settings:write.
| Tried | HTTP |
|---|---|
| leader cannot | 403 |
| admin | 200 |
PUT /api/admin/settings/{key}
Capability settings:write. Set one value, or clear it back to the code default with value: null.
| Tried | HTTP |
|---|---|
| bad value | 422 |
| set | 200 |
| clear to default | 200 |
| unknown key | 422 |
API keys
Bearer keys, honoured on /api/admin only. Scopes are a subset of the owner's capabilities; a key can never mint keys. LAN-only unless api_keys_from is anywhere.
GET /api/admin/keys
Capability apikeys:own. Your keys, newest first, with state (active / expired / revoked) and
| Tried | HTTP |
|---|---|
| admin token cannot own keys | 403 |
| own list | 200 |
POST /api/admin/keys
Capability apikeys:own. Mint a key. Body: label, scopes (list), expires_days (optional, 1-365).
| Tried | HTTP |
|---|---|
| unscopable scope | 422 |
| mint | 201 |
| a key cannot mint keys | 403 |
DELETE /api/admin/keys/{id}
Capability apikeys:own. Revoke one of your keys. The row stays. A key that is not yours is
| Tried | HTTP |
|---|---|
| revoke | 200 |
| revoke again | 409 |
| foreign/unknown key | 404 |
History
The action log, newest first. Admin and above.
GET /api/admin/history
Capability history:read. Newest first. kind is a prefix filter (calendar., nearby., login.).
| Tried | HTTP |
|---|---|
| leader cannot | 403 |
| admin, filtered | 200 |
People
Invites and reset links are returned once and handed over by the admin; there is no outbound mail. Grants are limited to what the caller holds. Owner-only: roles, disable, enable.
GET /api/admin/people
Capability people:invite_leader. Everyone with an account, their roles and memberships, active
| Tried | HTTP |
|---|---|
| leader cannot | 403 |
| admin | 200 |
POST /api/admin/people/invite
Capability people:invite_leader. Mint an invite. Body: email, global_role (optional), units
| Tried | HTTP |
|---|---|
| bad email | 422 |
| admin cannot grant owner | 403 |
| invite | 201 |
DELETE /api/admin/people/invite/{id}
Capability people:invite_leader.
| Tried | HTTP |
|---|---|
| revoke invite | 200 |
| revoke again | 404 |
PUT /api/admin/people/{id}/roles
Capability people:manage. Replace a person's global role and unit memberships. Body:
| Tried | HTTP |
|---|---|
| admin cannot change roles (owner only) | 403 |
| owner sets roles | 200 |
POST /api/admin/people/{id}/disable
Capability people:manage. Disable a person now: sessions end, keys stop, documents close. The
| Tried | HTTP |
|---|---|
| admin cannot disable | 403 |
| owner disables | 200 |
POST /api/admin/people/{id}/enable
Capability people:manage.
| Tried | HTTP |
|---|---|
| owner enables | 200 |
POST /api/admin/people/{id}/reset
Capability people:invite_admin. Mint a one-time password-reset link (24 h), returned ONCE. Their
| Tried | HTTP |
|---|---|
| admin mints a reset link | 201 |
| owner may reset an admin | 201 |
| unknown person | 404 |
Roster and family
Families, scouts, and a per-year checklist recording that a thing was collected, never the thing. Nothing medical is stored. /family is the signed-in parent's own households.
GET /api/admin/roster
Capability roster:write. Households with their scouts and this year's checklist. unit is a
| Tried | HTTP |
|---|---|
| member cannot | 403 |
| roster by unit | 200 |
POST /api/admin/roster/households
Capability roster:write. A family. Body: parent_name (required), email, phone, second_parent,
| Tried | HTTP |
|---|---|
| no parent | 422 |
| create family | 201 |
| import lead | 201 |
| import twice | 409 |
GET /api/admin/roster/households/{id}
Capability roster:write.
| Tried | HTTP |
|---|---|
| one family | 200 |
PATCH /api/admin/roster/households/{id}
Capability roster:write.
| Tried | HTTP |
|---|---|
| deactivate family | 200 |
POST /api/admin/roster/households/{id}/scouts
Capability roster:write. Body: first_name (required), last_name, unit_id (required), den, bsa_member_id.
| Tried | HTTP |
|---|---|
| add scout | 201 |
| bad bsa id | 422 |
PUT /api/admin/roster/households/{id}/people
Capability people:invite_leader. Which accounts belong to this family: body {person_ids: [...]}. This is
| Tried | HTTP |
|---|---|
| leader cannot link accounts | 403 |
| unknown person id | 422 |
| admin links the member | 200 |
PATCH /api/admin/roster/scouts/{id}
Capability roster:write.
| Tried | HTTP |
|---|---|
| edit scout | 200 |
PUT /api/admin/roster/scouts/{id}/checks/{item}
Capability roster:write. Mark an item collected for this year: body {done: true|false, note}.
| Tried | HTTP |
|---|---|
| mark dues | 200 |
| bad item | 422 |
GET /api/admin/family
Capability account:self. The signed-in person's own families: scouts, dens, and this year's
| Tried | HTTP |
|---|---|
| member sees own family | 200 |
| unlinked account sees none | 200 |
| inactive family drops off | 200 |
| anon | 401 |
Calendar
Writes go to Radicale. The site owns its two UID namespaces and refuses any other before a network call. DELETE really deletes.
GET /api/admin/calendar
Capability calendar:write. Every event the public calendar shows, newest first is NOT the order:
| Tried | HTTP |
|---|---|
| member cannot | 403 |
| list | 200 |
POST /api/admin/calendar
Capability calendar:write. Add an event. Body: title, date (YYYY-MM-DD), unit (pack|troop|both),
| Tried | HTTP |
|---|---|
| no title | 422 |
| create (2036) | 201 |
PUT /api/admin/calendar/{uid}
Capability calendar:write. Replace one site-owned event in full (same body as POST). A UID the
| Tried | HTTP |
|---|---|
| replace | 200 |
| foreign uid | 403 |
DELETE /api/admin/calendar/{uid}
Capability calendar:write. Remove one site-owned event from the store. Unlike everything else on
| Tried | HTTP |
|---|---|
| delete | 200 |
| delete again | 404 |
Facebook posts
The publisher reports; leaders read, cancel and edit. The image hash is verified on arrival and the image route is gated like the list. Cancel and reschedule go through the publisher's signed per-post link.
GET /api/admin/fbposts
Capability fbposts:read. Every post the publisher has reported, newest scheduled first, with
| Tried | HTTP |
|---|---|
| member cannot | 403 |
| listed as scheduled | 200 |
| published dropped from open list | 200 |
POST /api/admin/fbposts/ingest
Capability fbposts:ingest. scout-publisher reports a post. Body: id (/), unit,
| Tried | HTTP |
|---|---|
| leader cannot ingest | 403 |
| hash mismatch refused | 422 |
| ingest with image | 200 |
| past time reads as published | 200 |
GET /api/admin/fbposts/{id}/image
Capability fbposts:read. The stored image, behind the same gate as the list.
| Tried | HTTP |
|---|---|
| image anon | 401 |
| image gated | 200 |
POST /api/admin/fbposts/{id}/cancel
Capability fbposts:read. Cancel a scheduled post through the publisher's own signed link, then
| Tried | HTTP |
|---|---|
| cancel: publisher refuses a bad signature | 502 |
| cancel after time passed | 409 |
POST /api/admin/fbposts/{id}/reschedule
Capability fbposts:read. Edit and repost a scheduled post: body {message, scheduled_for}. The
| Tried | HTTP |
|---|---|
| reschedule: time too soon | 422 |
| reschedule: publisher refuses a bad signature | 502 |