API — course router¶
backend/src/routers/course.py (684 lines).
APIRouter(prefix="/course", tags=["course"]) (course.py:57).
Drip-fed course content with read-tracking, plus the vendored-content
body proxy (rate-limited 30/minute, _CMS_PROXY_RATE_LIMIT,
course.py:53).
| Method | Path | Rate limit | Auth | Response | Success | Errors |
|---|---|---|---|---|---|---|
| GET | /course/stages/{n}/content |
— | JWT | Page[ContentItemResponse] or bare list |
200 | 404 stage_not_found |
| GET | /course/content/{content_id} |
— | JWT | ContentItemResponse |
200 | 404 content_not_found (also masks "locked") |
| POST | /course/content/{content_id}/mark-read |
— | JWT | ContentCompletionResponse |
200 (idempotent) | 404 content_not_found |
| GET | /course/stages/{n}/progress |
— | JWT | CourseProgressResponse |
200 | 404 stage_not_found, 403 stage_locked |
| GET | /course/content/{content_id}/body |
30/min | JWT | ContentBodyResponse |
200 | 404 content_not_found, 502 content_unavailable |
| GET | /course/site-resources |
— | JWT | list[SiteResourceResponse] |
200 | — |
| GET | /course/site-resources/{slug}/body |
30/min | JWT | ContentBodyResponse |
200 | 404 content_not_found, 502 content_unavailable |
| GET | /course/stages/{n}/intro |
— | JWT | StageIntroResponse |
200 | 404 content_not_found (masks locked stage / missing intro) |
| GET | /course/stages/{n}/intro/body |
30/min | JWT | ContentBodyResponse |
200 | 404 content_not_found, 502 content_unavailable |
Notes:
- Listing on a locked stage is titles-only by design: "the course
drawer renders the full chapter map (every item locked with
urlnulled) while bodies and urls stay protected. Item detail … andmark_content_readremain gated — only this table-of-contents view is open on a locked stage" (course.py:261-265). Pagination applies after drip-feed filtering so the envelope'stotal"reflects the items the user can actually see" (course.py:256-259; BUG-INFRA-018). The drip math is domain/course, driven by the calendar day-in-stage. - BUG-COURSE-004 — the enumeration mask: on item detail, body,
intro, and intro-body, "stage locked" 403 collapses into the same 404
content_not_foundas a genuinely missing row, "so an attacker enumeratingcontent_idcannot distinguish 'row exists but locked for me' from 'row does not exist'" (course.py:300-308,566-572,646-651). The one intentional exception:/stages/{n}/progressreturns an explicit 403stage_locked(course.py:204,440-447) — the stage number is not a secret, only content rows are. - mark-read is idempotent: a pre-check serves the retry/refresh fast
path, and the
uq_contentcompletion_user_contentconstraint catches the concurrent loser viaIntegrityError, returning the existing row (closes the BUG-COURSE-002 TOCTOU,course.py:402-416; data-model/course-content). - Body proxy: raw Markdown comes from the vendored content
repository (
aptitude-coursepin); a broken repository surfaces as 502content_unavailablerather than a 500 (course.py:484-507). - Site resources (issue #395) are manifest-driven, auth-required but
not stage-gated; "without a usable manifest — the bootstrap state
before the first content pin — the list is simply empty"
(
course.py:577-590); unknown slugs are plain 404s (course.py:614-618). - Stage intro is ungated by
release_daybut gated by stage unlock (course.py:646-651).
DTOs: backend/src/schemas/course.py. Content pinning/manifest:
backend/src/content_config.py (see
infrastructure).
Grounded in adepthood@fbc529d, 2026-07-31.