Files
thethreemagi 752fb08d73 API.md: every admin route tested over HTTP and approved
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.
2026-09-04 20:27:19 -04:00

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