Moved to app/API.md so the build context carries it. The console renders it at /leaders/api. tests/smoke_admin.py 162 -> 164.
14 KiB
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/adminneeds a signed-in session cookie (s73_session, set by/login) or an API key asAuthorization: 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-glassX-Admin-Tokenis 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;
Zis accepted. Dates areYYYY-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, 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.