Files
thethreemagi d1dfa6df90 API.md ships in the image; GET /api/docs/approved serves it behind api:docs
Moved to app/API.md so the build context carries it. The console renders
it at /leaders/api. tests/smoke_admin.py 162 -> 164.
2026-09-04 20:26:45 -04:00

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/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, 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.