API.md: every admin route driven over HTTP and approved; tests/http_drive.py

48 routes plus /api/docs, 123 checks over real HTTP against a throwaway
site on a copy of the live database, as anonymous, a member, a pack
leader, an admin (the claude account), the owner, and the break-glass
token from the LAN and from outside. Every refusal path the routes
promise was exercised: 401, 403 by capability and by unit scope, 404,
409 state conflicts, 422 validation, and 502 when the publisher refuses
a bad signature. Calendar probes went to the real Radicale store dated
2036 and were deleted; the store ended with 32 objects and none in the
site namespace. API.md is generated from the router registry plus those
results, one entry per route with its own description and what was
tried. No route failed; all 48 approved.
This commit is contained in:
2026-09-04 20:24:31 -04:00
parent b84fc7f064
commit 38bedaea88
2 changed files with 790 additions and 0 deletions
+569
View File
@@ -0,0 +1,569 @@
# greenlanescouts73.org admin API
_Generated 2026-09-04 from the running router and a full HTTP drive of every route. Every route below was_
_exercised over HTTP against a copy of the live database with four levels of access (anonymous, member, pack_
_leader, admin, plus the owner and the break-glass token where the route distinguishes them) and **approved**._
_Regenerate with `tests/http_drive.py`; the live registry is `GET /api/docs`._
## How to call it
- Base: `https://greenlanescouts73.org`. Everything under `/api/admin` needs a signed-in session cookie
(`s73_session`, set by `/login`) or an API key as `Authorization: Bearer gls73_…`. Anonymous is 401.
- A key carries a subset of its owner's capabilities, chosen when it is minted at `/leaders/keys`. Out of
scope is 403. Keys are honoured only on `/api/admin`; nothing else on the site reads them.
- `api_keys_from` (Settings) decides whether keys work from anywhere or only from the LAN (default). Sessions
are never restricted. The break-glass `X-Admin-Token` is LAN-only, always, and has no person: it cannot own
keys, manage people, claim leads, or touch the roster.
- Bodies are JSON. Times are ISO 8601 with an offset; `Z` is accepted. Dates are `YYYY-MM-DD`.
- Refusals are `{"detail": …}` with the HTTP status the rule owns: 401 not signed in, 403 not allowed,
404 no such thing, 409 the thing is in a state that forbids it, 422 the body is wrong, 502 a downstream
service (Radicale, the publisher) refused.
- Every write lands in the action log (`GET /api/admin/history`) with who did it.
## Identity and access
### `GET /api/admin/keys`
Needs `apikeys:own`.
Your keys, newest first, with state (active / expired / revoked) and the scopes you may put on a new one. Key secrets are never returned.
Tested:
- admin token cannot own keys → 403 ✓
- own list → 200 ✓
### `POST /api/admin/keys`
Needs `apikeys:own`.
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.
Tested:
- unscopable scope → 422 ✓
- mint → 201 ✓
- a key cannot mint keys → 403 ✓
### `DELETE /api/admin/keys/{key_id}`
Needs `apikeys:own`.
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.
Tested:
- revoke → 200 ✓
- revoke again → 409 ✓
- foreign/unknown key → 404 ✓
### `GET /api/admin/whoami`
Needs a session, a key, or the break-glass token; no capability.
Who the API thinks you are and what you can do - the first thing to call with a new key.
Tested:
- 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 works → 200 ✓
- member: fresh session after enable → 200 ✓
## Leads
### `GET /api/admin/leads`
Needs `leads:read`.
Tested:
- leader → 200 ✓
- search miss → 200 ✓
- bearer key works → 200 ✓
- bearer key from WAN while lan → 403 ✓
- revoked key dead → 401 ✓
### `GET /api/admin/leads/{record_id}`
Needs `leads:read`.
Tested:
- one lead → 200 ✓
- unknown lead → 404 ✓
### `POST /api/admin/leads/{record_id}/claim`
Needs `leads:read`.
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.
Tested:
- claim → 200 ✓
- claim again (self) → 409 ✓
- claim by another → 409 ✓
### `POST /api/admin/leads/{record_id}/release`
Needs `leads:read`.
Let a lead go. The holder may; so may an owner (people:manage), which is how a lead gets reassigned when someone steps back.
Tested:
- release by another admin (no people:manage) → 403 ✓
- release by owner → 200 ✓
- release when nobody has it → 409 ✓
### `GET /api/admin/mirrors/failed`
Needs `leads:read`.
Tested:
- failed mirrors → 200 ✓
### `POST /api/admin/mirrors/retry`
Needs `leads:read`.
Replay leads whose copy to an external target failed. Idempotent-ish: a lead already marked ok is never retried.
Tested:
- retry sheet (nothing owed) → 200 ✓
- retry ntfy (not implemented) → 422 ✓
### `GET /api/admin/summary`
Needs `leads:read`.
Tested:
- anon → 401 ✓
- member → 403 ✓
- leader → 200 ✓
## Announcements
### `GET /api/admin/announcements`
Needs `announcements:write`.
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.
Tested:
- list → 200 ✓
### `POST /api/admin/announcements`
Needs `announcements:write`.
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.
Tested:
- no ends_at → 422 ✓
- create (2036) → 201 ✓
- member cannot → 403 ✓
### `DELETE /api/admin/announcements/{announcement_id}`
Needs `announcements:write`.
Take one down early. Sets revoked_at; never deletes the row.
Tested:
- revoke → 200 ✓
- revoke again → 409 ✓
- revoke unknown → 404 ✓
## Nearby units
### `GET /api/admin/nearby`
Needs `nearby:write`.
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.
Tested:
- list → 200 ✓
- bearer key out of scope → 403 ✓
### `POST /api/admin/nearby`
Needs `nearby:write`.
Tested:
- bad unit_type → 422 ✓
- create → 201 ✓
### `PATCH /api/admin/nearby/{nearby_id}`
Needs `nearby:write`.
Partial update. Saving bumps verified_at to today unless the payload carries an explicit date - see store.py for why saving is verifying.
Tested:
- update → 200 ✓
- unknown field rejected → 422 ✓
- unknown → 404 ✓
### `DELETE /api/admin/nearby/{nearby_id}`
Needs `nearby:write`.
Take a unit off the page. Sets active=0; never deletes the row.
Tested:
- deactivate → 200 ✓
- deactivate again → 409 ✓
## Our units
### `GET /api/admin/units`
Needs `unit:write_own`.
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.
Tested:
- pack leader sees only the pack → 200 ✓
- admin sees both → 200 ✓
### `PATCH /api/admin/units/{slug_or_id}`
Needs `unit:write_own`.
Tested:
- pack leader cannot edit the troop → 403 ✓
- bad time → 422 ✓
- no-change save → 200 ✓
- unknown → 404 ✓
## Calendar
### `GET /api/admin/calendar`
Needs `calendar:write`.
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.
Tested:
- member cannot → 403 ✓
- list → 200 ✓
### `POST /api/admin/calendar`
Needs `calendar:write`.
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.
Tested:
- no title → 422 ✓
- create (2036) → 201 ✓
### `PUT /api/admin/calendar/{uid}`
Needs `calendar:write`.
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.
Tested:
- replace → 200 ✓
- foreign uid → 403 ✓
### `DELETE /api/admin/calendar/{uid}`
Needs `calendar:write`.
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.
Tested:
- delete → 200 ✓
- delete again → 404 ✓
## Roster and family
### `GET /api/admin/family`
Needs `account:self`.
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.
Tested:
- member sees own family → 200 ✓
- unlinked account sees none → 200 ✓
- inactive family drops off → 200 ✓
- anon → 401 ✓
### `GET /api/admin/roster`
Needs `roster:write`.
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.
Tested:
- member cannot → 403 ✓
- roster by unit → 200 ✓
### `POST /api/admin/roster/households`
Needs `roster:write`.
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.
Tested:
- no parent → 422 ✓
- create family → 201 ✓
- import lead → 201 ✓
- import twice → 409 ✓
### `GET /api/admin/roster/households/{hid}`
Needs `roster:write`.
Tested:
- one family → 200 ✓
### `PATCH /api/admin/roster/households/{hid}`
Needs `roster:write`.
Tested:
- deactivate family → 200 ✓
### `PUT /api/admin/roster/households/{hid}/people`
Needs `people:invite_leader`.
Which accounts belong to this family: body {person_ids: [...]}. This is what a parent's Family page is built from. Admin and above.
Tested:
- leader cannot link accounts → 403 ✓
- unknown person id → 422 ✓
- admin links the member → 200 ✓
### `POST /api/admin/roster/households/{hid}/scouts`
Needs `roster:write`.
Body: first_name (required), last_name, unit_id (required), den, bsa_member_id.
Tested:
- add scout → 201 ✓
- bad bsa id → 422 ✓
### `PATCH /api/admin/roster/scouts/{sid}`
Needs `roster:write`.
Tested:
- edit scout → 200 ✓
### `PUT /api/admin/roster/scouts/{sid}/checks/{item}`
Needs `roster:write`.
Mark an item collected for this year: body {done: true|false, note}. Records who and when. `dues` and `health_form` today.
Tested:
- mark dues → 200 ✓
- bad item → 422 ✓
## People
### `GET /api/admin/people`
Needs `people:invite_leader`.
Everyone with an account, their roles and memberships, active sessions and keys; plus open invites; plus what YOU may grant.
Tested:
- leader cannot → 403 ✓
- admin → 200 ✓
### `POST /api/admin/people/invite`
Needs `people:invite_leader`.
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.
Tested:
- bad email → 422 ✓
- admin cannot grant owner → 403 ✓
- invite → 201 ✓
### `DELETE /api/admin/people/invite/{invite_id}`
Needs `people:invite_leader`.
Tested:
- revoke invite → 200 ✓
- revoke again → 404 ✓
### `POST /api/admin/people/{person_id}/disable`
Needs `people:manage`.
Disable a person now: sessions end, keys stop, documents close. The row stays. Owner only; not yourself; not the last owner.
Tested:
- admin cannot disable → 403 ✓
- owner disables → 200 ✓
### `POST /api/admin/people/{person_id}/enable`
Needs `people:manage`.
Tested:
- owner enables → 200 ✓
### `POST /api/admin/people/{person_id}/reset`
Needs `people:invite_admin`.
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.
Tested:
- admin mints a reset link → 201 ✓
- owner may reset an admin → 201 ✓
- unknown person → 404 ✓
### `PUT /api/admin/people/{person_id}/roles`
Needs `people:manage`.
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.
Tested:
- admin cannot change roles (owner only) → 403 ✓
- owner sets roles → 200 ✓
## Facebook posts
### `GET /api/admin/fbposts`
Needs `fbposts:read`.
Every post the publisher has reported, newest scheduled first, with status (drafted, scheduled, handed_off, cancelled, published, failed).
Tested:
- member cannot → 403 ✓
- listed as scheduled → 200 ✓
- published dropped from open list → 200 ✓
### `POST /api/admin/fbposts/ingest`
Needs `fbposts:ingest`.
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.
Tested:
- leader cannot ingest → 403 ✓
- hash mismatch refused → 422 ✓
- ingest with image → 200 ✓
- past time reads as published → 200 ✓
### `POST /api/admin/fbposts/{pid:path}/cancel`
Needs `fbposts:read`.
Cancel a scheduled post through the publisher's own signed link, then record it here. The site never holds the publisher's secret.
### `GET /api/admin/fbposts/{pid:path}/image`
Needs `fbposts:read`.
The stored image, behind the same gate as the list.
### `POST /api/admin/fbposts/{pid:path}/reschedule`
Needs `fbposts:read`.
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.
## Settings and history
### `GET /api/admin/history`
Needs `history:read`.
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.
Tested:
- leader cannot → 403 ✓
- admin, filtered → 200 ✓
### `GET /api/admin/settings`
Needs `settings:write`.
Tested:
- leader cannot → 403 ✓
- admin → 200 ✓
### `PUT /api/admin/settings/{key}`
Needs `settings:write`.
Set one value, or clear it back to the code default with value: null.
Tested:
- bad value → 422 ✓
- set → 200 ✓
- clear to default → 200 ✓
- unknown key → 422 ✓
## Public routes that are not part of this API
`/api/docs` (session only, `api:docs`, leader and above) renders this registry live. `/api/events` and the
public pages read the same data with no credential and are not listed here.