openapi: 3.1.0 info: title: FablePool Public API version: 1.0.0 summary: Public API for the FablePool community learning platform. description: | FablePool is an open-source, community-driven interactive learning platform. This specification covers the public v1 API: problems, courses, users, tags, topics, progress, reviews, discussions, search, reports, webhooks, and import/export of versioned Open Educational Resource (OER) packages. ## Conventions * **Base path** — all routes below are relative to `/api/v1`. * **IDs** — all primary identifiers are UUIDv4 strings unless stated otherwise. * **Timestamps** — RFC 3339 / ISO 8601 in UTC (`2025-01-30T12:00:00Z`). * **Errors** — RFC 9457 `application/problem+json` (see `Error` schema). * **Pagination** — cursor-based; see the `cursor` / `page_size` parameters and the `PageMeta` schema. `page_size` is capped at 100. * **Rate limiting** — every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. `429` responses include `Retry-After`. Default buckets: anonymous read `60/min`, authenticated read `600/min`, write `60/min`, search `30/min`, OER import/export `6/hour`. Per-operation overrides are annotated with the `x-rate-limit` extension. * **Permissions** — each operation carries an `x-permissions` extension: `authentication` (`none` | `optional` | `required`), `roles` (any-of role list; empty = any authenticated user), and `object` (object-level rule evaluated server-side, e.g. "author or moderator"). * **Content format** — structured content bodies conform to the JSON Schema suite published at `schemas/v1/` in the repository (`content-document.json`, `problem-content.json`, `lesson-content.json`, `oer-package.json`). * **Soft deletion** — `DELETE` operations soft-delete; deleted resources return `404` to non-privileged callers but remain in audit logs. * **Licensing** — all published educational content is CC BY-SA 4.0; attribution chains for forks are exposed via `forked_from` and `attribution` fields. license: name: AGPL-3.0-only identifier: AGPL-3.0-only contact: name: FablePool maintainers url: https://github.com/fablepool/fablepool servers: - url: https://api.fablepool.org/api/v1 description: Production - url: http://localhost:8000/api/v1 description: Local development security: - bearerAuth: [] - apiKey: [] - {} tags: - name: Users description: Public profiles and the authenticated user's account. - name: Problems description: Community-authored problems, versions, hints, solutions, attempts. - name: Courses description: Courses, versions, modules, lessons, enrollment. - name: Reviews description: Peer-review workflow for submitted content versions. - name: Discussions description: Discussion threads and comments attached to problems/courses. - name: Taxonomy description: Tags and topic hierarchy. - name: Progress description: Progress tracking, spaced repetition, streaks, bookmarks. - name: Search description: Full-text and faceted search across published content. - name: Moderation description: Reports and moderation actions. - name: OER description: Import/export of versioned Open Educational Resource packages. - name: Webhooks description: Webhook subscription management. paths: # ──────────────────────────────────────────────────────────── Users ── /users: get: tags: [Users] operationId: listUsers summary: List public user profiles x-permissions: { authentication: none, roles: [], object: null } parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/PageSize' - name: role in: query schema: { type: string, enum: [learner, contributor, reviewer, moderator, admin] } - name: ordering in: query schema: { type: string, enum: [reputation, -reputation, created_at, -created_at], default: -reputation } - name: q in: query description: Username/display-name prefix filter. schema: { type: string, maxLength: 64 } responses: '200': description: Page of user summaries. headers: { $ref: '#/components/headers/RateLimitBundle' } content: application/json: schema: allOf: - $ref: '#/components/schemas/PageMeta' - type: object properties: results: type: array items: { $ref: '#/components/schemas/UserSummary' } '429': { $ref: '#/components/responses/RateLimited' } /users/{username}: get: tags: [Users] operationId: getUser summary: Get a public user profile x-permissions: { authentication: none, roles: [], object: null } parameters: - name: username in: path required: true schema: { type: string, pattern: '^[a-zA-Z0-9_.-]{3,32}$' } responses: '200': description: Public profile. content: application/json: schema: { $ref: '#/components/schemas/UserDetail' } '404': { $ref: '#/components/responses/NotFound' } /me: get: tags: [Users] operationId: getMe summary: Get the authenticated user's account x-permissions: { authentication: required, roles: [], object: self } responses: '200': description: Authenticated account. content: application/json: schema: { $ref: '#/components/schemas/Me' } '401': { $ref: '#/components/responses/Unauthorized' } patch: tags: [Users] operationId: updateMe summary: Update profile, locale, or notification settings x-permissions: { authentication: required, roles: [], object: self } x-rate-limit: { bucket: write, limit: 60/min } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/MeUpdate' } responses: '200': description: Updated account. content: application/json: schema: { $ref: '#/components/schemas/Me' } '400': { $ref: '#/components/responses/ValidationError' } '401': { $ref: '#/components/responses/Unauthorized' } /me/notifications: get: tags: [Users] operationId: listNotifications summary: List the authenticated user's notifications x-permissions: { authentication: required, roles: [], object: self } parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/PageSize' - name: unread in: query schema: { type: boolean } responses: '200': description: Page of notifications. content: application/json: schema: allOf: - $ref: '#/components/schemas/PageMeta' - type: object properties: results: type: array items: { $ref: '#/components/schemas/Notification' } '401': { $ref: '#/components/responses/Unauthorized' } /me/notifications/{notificationId}/read: post: tags: [Users] operationId: markNotificationRead summary: Mark a notification as read x-permissions: { authentication: required, roles: [], object: "notification recipient" } parameters: - $ref: '#/components/parameters/NotificationId' responses: '204': { description: Marked read. } '404': { $ref: '#/components/responses/NotFound' } /me/bookmarks: get: tags: [Progress] operationId: listBookmarks summary: List the authenticated user's bookmarks x-permissions: { authentication: required, roles: [], object: self } parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/PageSize' - name: target_type in: query schema: { type: string, enum: [problem, course, lesson] } responses: '200': description: Page of bookmarks. content: application/json: schema: allOf: - $ref: '#/components/schemas/PageMeta' - type: object properties: results: type: array items: { $ref: '#/components/schemas/Bookmark' } '401': { $ref: '#/components/responses/Unauthorized' } post: tags: [Progress] operationId: createBookmark summary: Bookmark a problem, course, or lesson x-permissions: { authentication: required, roles: [], object: self } x-rate-limit: { bucket: write, limit: 60/min } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/BookmarkCreate' } responses: '201': description: Bookmark created (idempotent on duplicate). content: application/json: schema: { $ref: '#/components/schemas/Bookmark' } '400': { $ref: '#/components/responses/ValidationError' } /me/bookmarks/{bookmarkId}: delete: tags: [Progress] operationId: deleteBookmark summary: Remove a bookmark x-permissions: { authentication: required, roles: [], object: "bookmark owner" } parameters: - name: bookmarkId in: path required: true schema: { type: string, format: uuid } responses: '204': { description: Removed. } '404': { $ref: '#/components/responses/NotFound' } /me/progress: get: tags: [Progress] operationId: getMyProgress summary: Overall progress summary for the authenticated user x-permissions: { authentication: required, roles: [], object: self } responses: '200': description: Progress overview. content: application/json: schema: { $ref: '#/components/schemas/ProgressOverview' } '401': { $ref: '#/components/responses/Unauthorized' } /me/progress/courses/{courseId}: get: tags: [Progress] operationId: getMyCourseProgress summary: Per-lesson progress within one enrolled course x-permissions: { authentication: required, roles: [], object: "enrolled learner" } parameters: - $ref: '#/components/parameters/CourseId' responses: '200': description: Course progress detail. content: application/json: schema: { $ref: '#/components/schemas/CourseProgress' } '404': { $ref: '#/components/responses/NotFound' } /me/review-queue: get: tags: [Progress] operationId: getReviewQueue summary: Spaced-repetition queue of problems due for review description: > Returns problems scheduled by the SM-2-style scheduler, ordered by due date. Each item includes the scheduling state so clients can render intervals offline. x-permissions: { authentication: required, roles: [], object: self } parameters: - $ref: '#/components/parameters/PageSize' responses: '200': description: Due review items. content: application/json: schema: type: object required: [results, due_count] properties: due_count: { type: integer, minimum: 0 } results: type: array items: { $ref: '#/components/schemas/RepetitionItem' } '401': { $ref: '#/components/responses/Unauthorized' } /me/streak: get: tags: [Progress] operationId: getMyStreak summary: Current and longest daily activity streak x-permissions: { authentication: required, roles: [], object: self } responses: '200': description: Streak info. content: application/json: schema: { $ref: '#/components/schemas/StreakInfo' } # ───────────────────────────────────────────────────────── Problems ── /problems: get: tags: [Problems] operationId: listProblems summary: List problems description: > Anonymous callers see only published problems. Authenticated callers additionally see their own drafts when `status` filters request them; reviewers/moderators may filter by any status. x-permissions: { authentication: optional, roles: [], object: "status visibility per role" } parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/PageSize' - name: topic in: query schema: { type: string } description: Topic slug. - name: tags in: query schema: { type: array, items: { type: string } } style: form explode: false description: Comma-separated tag slugs (AND semantics). - name: difficulty in: query schema: { type: integer, minimum: 1, maximum: 5 } - name: difficulty_min in: query schema: { type: integer, minimum: 1, maximum: 5 } - name: difficulty_max in: query schema: { type: integer, minimum: 1, maximum: 5 } - name: problem_type in: query schema: { $ref: '#/components/schemas/ProblemType' } - name: author in: query schema: { type: string } description: Author username. - name: status in: query schema: { $ref: '#/components/schemas/ContentStatus' } - name: prerequisite in: query schema: { type: string, format: uuid } description: Only problems listing this problem ID as a prerequisite. - name: ordering in: query schema: type: string enum: [created_at, -created_at, difficulty, -difficulty, attempts, -attempts] default: -created_at responses: '200': description: Page of problem summaries. headers: { $ref: '#/components/headers/RateLimitBundle' } content: application/json: schema: allOf: - $ref: '#/components/schemas/PageMeta' - type: object properties: results: type: array items: { $ref: '#/components/schemas/ProblemSummary' } post: tags: [Problems] operationId: createProblem summary: Create a new problem (initial draft version) x-permissions: { authentication: required, roles: [contributor, reviewer, moderator, admin], object: null } x-rate-limit: { bucket: write, limit: 60/min } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ProblemCreate' } responses: '201': description: Problem created with version 1 in `draft`. content: application/json: schema: { $ref: '#/components/schemas/ProblemDetail' } '400': { $ref: '#/components/responses/ValidationError' } '403': { $ref: '#/components/responses/Forbidden' } /problems/{problemId}: parameters: - $ref: '#/components/parameters/ProblemId' get: tags: [Problems] operationId: getProblem summary: Get a problem (published version by default) x-permissions: { authentication: optional, roles: [], object: "published, or author/reviewer/moderator for unpublished" } responses: '200': description: Problem detail. Answer keys are never included; the `answer_spec` is redacted to presentation data only. content: application/json: schema: { $ref: '#/components/schemas/ProblemDetail' } '404': { $ref: '#/components/responses/NotFound' } patch: tags: [Problems] operationId: updateProblemMeta summary: Update problem metadata (tags, topics, difficulty, prerequisites) description: Content changes must go through a new version; this only edits metadata. x-permissions: { authentication: required, roles: [], object: "author or moderator" } x-rate-limit: { bucket: write, limit: 60/min } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ProblemMetaUpdate' } responses: '200': description: Updated problem. content: application/json: schema: { $ref: '#/components/schemas/ProblemDetail' } '403': { $ref: '#/components/responses/Forbidden' } delete: tags: [Problems] operationId: deleteProblem summary: Soft-delete a problem x-permissions: { authentication: required, roles: [], object: "author (if never published) or moderator" } responses: '204': { description: Soft-deleted. } '403': { $ref: '#/components/responses/Forbidden' } /problems/{problemId}/versions: parameters: - $ref: '#/components/parameters/ProblemId' get: tags: [Problems] operationId: listProblemVersions summary: List versions of a problem with changelogs x-permissions: { authentication: optional, roles: [], object: "published history public; drafts visible to author/reviewer/moderator" } parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/PageSize' responses: '200': description: Version history (newest first). content: application/json: schema: allOf: - $ref: '#/components/schemas/PageMeta' - type: object properties: results: type: array items: { $ref: '#/components/schemas/ProblemVersion' } post: tags: [Problems] operationId: createProblemVersion summary: Create a new draft version x-permissions: { authentication: required, roles: [], object: "author or moderator" } x-rate-limit: { bucket: write, limit: 60/min } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ProblemVersionCreate' } responses: '201': description: New draft version. content: application/json: schema: { $ref: '#/components/schemas/ProblemVersion' } '400': { $ref: '#/components/responses/ValidationError' } '409': description: A draft version already exists; finish or discard it first. content: application/problem+json: schema: { $ref: '#/components/schemas/Error' } /problems/{problemId}/versions/{versionNumber}: parameters: - $ref: '#/components/parameters/ProblemId' - $ref: '#/components/parameters/VersionNumber' get: tags: [Problems] operationId: getProblemVersion summary: Get a specific version of a problem x-permissions: { authentication: optional, roles: [], object: "published versions public; others author/reviewer/moderator" } responses: '200': description: Version detail. content: application/json: schema: { $ref: '#/components/schemas/ProblemVersion' } '404': { $ref: '#/components/responses/NotFound' } /problems/{problemId}/versions/{versionNumber}/submit: post: tags: [Problems] operationId: submitProblemVersion summary: Submit a draft version for peer review description: Transitions `draft → submitted` and opens a Review. Fires the `review.requested` webhook. x-permissions: { authentication: required, roles: [], object: author } x-rate-limit: { bucket: write, limit: 60/min } parameters: - $ref: '#/components/parameters/ProblemId' - $ref: '#/components/parameters/VersionNumber' responses: '200': description: Version submitted; review opened. content: application/json: schema: { $ref: '#/components/schemas/Review' } '409': description: Version is not in `draft` state. content: application/problem+json: schema: { $ref: '#/components/schemas/Error' } /problems/{problemId}/versions/{versionNumber}/rollback: post: tags: [Problems] operationId: rollbackProblem summary: Roll back the published pointer to an earlier accepted version description: > Creates an audit-log entry and re-publishes the named version. Only previously accepted versions may be rollback targets. x-permissions: { authentication: required, roles: [moderator, admin], object: null } parameters: - $ref: '#/components/parameters/ProblemId' - $ref: '#/components/parameters/VersionNumber' requestBody: required: true content: application/json: schema: type: object required: [reason] properties: reason: { type: string, minLength: 10, maxLength: 2000 } responses: '200': description: Rolled back. content: application/json: schema: { $ref: '#/components/schemas/ProblemDetail' } '409': description: Target version was never accepted. content: application/problem+json: schema: { $ref: '#/components/schemas/Error' } /problems/{problemId}/fork: post: tags: [Problems] operationId: forkProblem summary: Fork a published problem description: > Creates a new problem owned by the caller, with `forked_from` set and an attribution chain preserved per CC BY-SA requirements. x-permissions: { authentication: required, roles: [contributor, reviewer, moderator, admin], object: null } x-rate-limit: { bucket: write, limit: 30/min } parameters: - $ref: '#/components/parameters/ProblemId' responses: '201': description: Fork created as a draft. content: application/json: schema: { $ref: '#/components/schemas/ProblemDetail' } '404': { $ref: '#/components/responses/NotFound' } /problems/{problemId}/hints: get: tags: [Problems] operationId: listHints summary: List hints for the published version (ordered) description: Hint bodies are revealed one at a time client-side; requesting a hint is recorded against the active attempt. x-permissions: { authentication: optional, roles: [], object: null } parameters: - $ref: '#/components/parameters/ProblemId' responses: '200': description: Ordered hints. content: application/json: schema: type: object properties: results: type: array items: { $ref: '#/components/schemas/Hint' } /problems/{problemId}/solutions: get: tags: [Problems] operationId: listSolutions summary: List community solutions description: > Gated: returned only after the caller has solved the problem, exhausted attempts, or explicitly given up (recorded on the attempt). Anonymous callers receive `403`. x-permissions: { authentication: required, roles: [], object: "solved / gave up, or author/reviewer/moderator" } parameters: - $ref: '#/components/parameters/ProblemId' - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/PageSize' responses: '200': description: Solutions, official first then by votes. content: application/json: schema: allOf: - $ref: '#/components/schemas/PageMeta' - type: object properties: results: type: array items: { $ref: '#/components/schemas/Solution' } '403': { $ref: '#/components/responses/Forbidden' } /problems/{problemId}/attempts: parameters: - $ref: '#/components/parameters/ProblemId' get: tags: [Problems] operationId: listMyAttempts summary: List the caller's attempts on this problem x-permissions: { authentication: required, roles: [], object: "attempt owner" } parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/PageSize' responses: '200': description: Attempts, newest first. content: application/json: schema: allOf: - $ref: '#/components/schemas/PageMeta' - type: object properties: results: type: array items: { $ref: '#/components/schemas/ProblemAttempt' } post: tags: [Problems] operationId: submitAttempt summary: Submit an answer attempt description: > Synchronous grading for multiple_choice, numeric, expression, ordering, matching, and widget answers. `code` and `free_text` (proof) answers are graded asynchronously — the response returns `grading: pending` and the final result is delivered via polling this attempt or the `attempt.graded` webhook. x-permissions: { authentication: required, roles: [], object: self } x-rate-limit: { bucket: attempts, limit: 30/min } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/AttemptCreate' } responses: '201': description: Graded (or pending) attempt. content: application/json: schema: { $ref: '#/components/schemas/ProblemAttempt' } '400': { $ref: '#/components/responses/ValidationError' } '429': { $ref: '#/components/responses/RateLimited' } /problems/{problemId}/attempts/{attemptId}: get: tags: [Problems] operationId: getAttempt summary: Get one attempt (poll for async grading) x-permissions: { authentication: required, roles: [], object: "attempt owner or moderator" } parameters: - $ref: '#/components/parameters/ProblemId' - name: attemptId in: path required: true schema: { type: string, format: uuid } responses: '200': description: Attempt. content: application/json: schema: { $ref: '#/components/schemas/ProblemAttempt' } '404': { $ref: '#/components/responses/NotFound' } /problems/{problemId}/discussion: parameters: - $ref: '#/components/parameters/ProblemId' get: tags: [Discussions] operationId: listProblemThreads summary: List discussion threads on a problem x-permissions: { authentication: none, roles: [], object: null } parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/PageSize' - name: contains_spoilers in: query schema: { type: boolean } responses: '200': description: Threads. content: application/json: schema: allOf: - $ref: '#/components/schemas/PageMeta' - type: object properties: results: type: array items: { $ref: '#/components/schemas/DiscussionThread' } post: tags: [Discussions] operationId: createProblemThread summary: Open a new discussion thread on a problem x-permissions: { authentication: required, roles: [], object: null } x-rate-limit: { bucket: write, limit: 20/min } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ThreadCreate' } responses: '201': description: Thread created. content: application/json: schema: { $ref: '#/components/schemas/DiscussionThread' } # ────────────────────────────────────────────────────────── Courses ── /courses: get: tags: [Courses] operationId: listCourses summary: List courses x-permissions: { authentication: optional, roles: [], object: "status visibility per role" } parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/PageSize' - name: topic in: query schema: { type: string } - name: tags in: query schema: { type: array, items: { type: string } } style: form explode: false - name: difficulty in: query schema: { type: integer, minimum: 1, maximum: 5 } - name: author in: query schema: { type: string } - name: status in: query schema: { $ref: '#/components/schemas/ContentStatus' } - name: ordering in: query schema: type: string enum: [created_at, -created_at, enrollments, -enrollments, title] default: -enrollments responses: '200': description: Page of course summaries. content: application/json: schema: allOf: - $ref: '#/components/schemas/PageMeta' - type: object properties: results: type: array items: { $ref: '#/components/schemas/CourseSummary' } post: tags: [Courses] operationId: createCourse summary: Create a course (initial draft version) x-permissions: { authentication: required, roles: [contributor, reviewer, moderator, admin], object: null } x-rate-limit: { bucket: write, limit: 30/min } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CourseCreate' } responses: '201': description: Course created with version 1 in `draft`. content: application/json: schema: { $ref: '#/components/schemas/CourseDetail' } '400': { $ref: '#/components/responses/ValidationError' } '403': { $ref: '#/components/responses/Forbidden' } /courses/{courseId}: parameters: - $ref: '#/components/parameters/CourseId' get: tags: [Courses] operationId: getCourse summary: Get a course (published version by default) x-permissions: { authentication: optional, roles: [], object: "published, or author/reviewer/moderator for unpublished" } responses: '200': description: Course detail with module/lesson outline. content: application/json: schema: { $ref: '#/components/schemas/CourseDetail' } '404': { $ref: '#/components/responses/NotFound' } patch: tags: [Courses] operationId: updateCourseMeta summary: Update course metadata x-permissions: { authentication: required, roles: [], object: "author or moderator" } x-rate-limit: { bucket: write, limit: 60/min } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CourseMetaUpdate' } responses: '200': description: Updated course. content: application/json: schema: { $ref: '#/components/schemas/CourseDetail' } delete: tags: [Courses] operationId: deleteCourse summary: Soft-delete a course x-permissions: { authentication: required, roles: [], object: "author (if never published) or moderator" } responses: '204': { description: Soft-deleted. } '403': { $ref: '#/components/responses/Forbidden' } /courses/{courseId}/versions: parameters: - $ref: '#/components/parameters/CourseId' get: tags: [Courses] operationId: listCourseVersions summary: List versions of a course x-permissions: { authentication: optional, roles: [], object: "published history public" } parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/PageSize' responses: '200': description: Version history. content: application/json: schema: allOf: - $ref: '#/components/schemas/PageMeta' - type: object properties: results: type: array items: { $ref: '#/components/schemas/CourseVersion' } post: tags: [Courses] operationId: createCourseVersion summary: Create a new draft course version x-permissions: { authentication: required, roles: [], object: "author or moderator" } x-rate-limit: { bucket: write, limit: 30/min } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CourseVersionCreate' } responses: '201': description: New draft version. content: application/json: schema: { $ref: '#/components/schemas/CourseVersion' } '409': description: A draft version already exists. content: application/problem+json: schema: { $ref: '#/components/schemas/Error' } /courses/{courseId}/versions/{versionNumber}: get: tags: [Courses] operationId: getCourseVersion summary: Get a specific course version with full structure x-permissions: { authentication: optional, roles: [], object: "published versions public; others author/reviewer/moderator" } parameters: - $ref: '#/components/parameters/CourseId' - $ref: '#/components/parameters/VersionNumber' responses: '200': description: Course version. content: application/json: schema: { $ref: '#/components/schemas/CourseVersion' } '404': { $ref: '#/components/responses/NotFound' } /courses/{courseId}/versions/{versionNumber}/submit: post: tags: [Courses] operationId: submitCourseVersion summary: Submit a draft course version for review x-permissions: { authentication: required, roles: [], object: author } x-rate-limit: { bucket: write, limit: 30/min } parameters: - $ref: '#/components/parameters/CourseId' - $ref: '#/components/parameters/VersionNumber' responses: '200': description: Submitted; review opened. content: application/json: schema: { $ref: '#/components/schemas/Review' } '409': description: Version not in `draft` state, or references unpublished problems. content: application/problem+json: schema: { $ref: '#/components/schemas/Error' } /courses/{courseId}/versions/{versionNumber}/rollback: post: tags: [Courses] operationId: rollbackCourse summary: Roll back the published pointer to an earlier accepted version x-permissions: { authentication: required, roles: [moderator, admin], object: null } parameters: - $ref: '#/components/parameters/CourseId' - $ref: '#/components/parameters/VersionNumber' requestBody: required: true content: application/json: schema: type: object required: [reason] properties: reason: { type: string, minLength: 10, maxLength: 2000 } responses: '200': description: Rolled back. content: application/json: schema: { $ref: '#/components/schemas/CourseDetail' } /courses/{courseId}/fork: post: tags: [Courses] operationId: forkCourse summary: Fork a published course x-permissions: { authentication: required, roles: [contributor, reviewer, moderator, admin], object: null } x-rate-limit: { bucket: write, limit: 10/min } parameters: - $ref: '#/components/parameters/CourseId' responses: '201': description: Fork created as a draft, attribution preserved. content: application/json: schema: { $ref: '#/components/schemas/CourseDetail' } /courses/{courseId}/lessons/{lessonId}: get: tags: [Courses] operationId: getLesson summary: Get one lesson with full structured content x-permissions: { authentication: optional, roles: [], object: "published course, or author/reviewer/moderator" } parameters: - $ref: '#/components/parameters/CourseId' - name: lessonId in: path required: true schema: { type: string, format: uuid } responses: '200': description: Lesson with content document. content: application/json: schema: { $ref: '#/components/schemas/Lesson' } '404': { $ref: '#/components/responses/NotFound' } /courses/{courseId}/enroll: post: tags: [Courses] operationId: enroll summary: Enroll in a published course x-permissions: { authentication: required, roles: [], object: self } x-rate-limit: { bucket: write, limit: 30/min } parameters: - $ref: '#/components/parameters/CourseId' responses: '201': description: Enrolled (idempotent). content: application/json: schema: { $ref: '#/components/schemas/Enrollment' } '404': { $ref: '#/components/responses/NotFound' } delete: tags: [Courses] operationId: unenroll summary: Leave a course (progress retained) x-permissions: { authentication: required, roles: [], object: self } parameters: - $ref: '#/components/parameters/CourseId' responses: '204': { description: Unenrolled. } /courses/{courseId}/discussion: parameters: - $ref: '#/components/parameters/CourseId' get: tags: [Discussions] operationId: listCourseThreads summary: List discussion threads on a course x-permissions: { authentication: none, roles: [], object: null } parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/PageSize' responses: '200': description: Threads. content: application/json: schema: allOf: - $ref: '#/components/schemas/PageMeta' - type: object properties: results: type: array items: { $ref: '#/components/schemas/DiscussionThread' } post: tags: [Discussions] operationId: createCourseThread summary: Open a discussion thread on a course x-permissions: { authentication: required, roles: [], object: null } x-rate-limit: { bucket: write, limit: 20/min } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ThreadCreate' } responses: '201': description: Thread created. content: application/json: schema: { $ref: '#/components/schemas/DiscussionThread' } # ──────────────────────────────────────────────────────── Discussions ── /discussions/{threadId}/comments: parameters: - name: threadId in: path required: true schema: { type: string, format: uuid } get: tags: [Discussions] operationId: listComments summary: List comments in a thread (flat, parent-linked) x-permissions: { authentication: none, roles: [], object: null } parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/PageSize' responses: '200': description: Comments oldest-first. content: application/json: schema: allOf: - $ref: '#/components/schemas/PageMeta' - type: object properties: results: type: array items: { $ref: '#/components/schemas/DiscussionComment' } post: tags: [Discussions] operationId: createComment summary: Post a comment (optionally as a reply) x-permissions: { authentication: required, roles: [], object: "thread not locked" } x-rate-limit: { bucket: write, limit: 20/min } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/CommentCreate' } responses: '201': description: Comment posted. content: application/json: schema: { $ref: '#/components/schemas/DiscussionComment' } '403': { $ref: '#/components/responses/Forbidden' } /votes: post: tags: [Discussions] operationId: castVote summary: Up/down-vote a comment or solution (idempotent toggle) x-permissions: { authentication: required, roles: [], object: "not own content" } x-rate-limit: { bucket: write, limit: 60/min } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/VoteCreate' } responses: '200': description: Resulting vote state and new score. content: application/json: schema: { $ref: '#/components/schemas/VoteResult' } '400': { $ref: '#/components/responses/ValidationError' } # ─────────────────────────────────────────────────────────── Reviews ── /reviews: get: tags: [Reviews] operationId: listReviews summary: List reviews (review queue) x-permissions: { authentication: required, roles: [reviewer, moderator, admin], object: "authors also see reviews of their own submissions" } parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/PageSize' - name: state in: query schema: { type: string, enum: [open, claimed, changes_requested, decided] } - name: content_type in: query schema: { type: string, enum: [problem, course] } - name: topic in: query schema: { type: string } - name: reviewer in: query schema: { type: string } responses: '200': description: Page of reviews. content: application/json: schema: allOf: - $ref: '#/components/schemas/PageMeta' - type: object properties: results: type: array items: { $ref: '#/components/schemas/Review' } '403': { $ref: '#/components/responses/Forbidden' } /reviews/{reviewId}: get: tags: [Reviews] operationId: getReview summary: Get one review with decision history x-permissions: { authentication: required, roles: [reviewer, moderator, admin], object: "or submission author" } parameters: - $ref: '#/components/parameters/ReviewId' responses: '200': description: Review detail. content: application/json: schema: { $ref: '#/components/schemas/Review' } '404': { $ref: '#/components/responses/NotFound' } /reviews/{reviewId}/claim: post: tags: [Reviews] operationId: claimReview summary: Claim an open review description: Reviewers cannot claim reviews of their own submissions or forks thereof. x-permissions: { authentication: required, roles: [reviewer, moderator, admin], object: "not submission author" } x-rate-limit: { bucket: write, limit: 30/min } parameters: - $ref: '#/components/parameters/ReviewId' responses: '200': description: Claimed. content: application/json: schema: { $ref: '#/components/schemas/Review' } '409': description: Already claimed or decided. content: application/problem+json: schema: { $ref: '#/components/schemas/Error' } /reviews/{reviewId}/comments: parameters: - $ref: '#/components/parameters/ReviewId' get: tags: [Reviews] operationId: listReviewComments summary: List review comments (optionally anchored to content blocks) x-permissions: { authentication: required, roles: [reviewer, moderator, admin], object: "or submission author" } parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/PageSize' responses: '200': description: Comments. content: application/json: schema: allOf: - $ref: '#/components/schemas/PageMeta' - type: object properties: results: type: array items: { $ref: '#/components/schemas/ReviewComment' } post: tags: [Reviews] operationId: createReviewComment summary: Add a review comment x-permissions: { authentication: required, roles: [reviewer, moderator, admin], object: "or submission author (replies)" } x-rate-limit: { bucket: write, limit: 30/min } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ReviewCommentCreate' } responses: '201': description: Comment added. content: application/json: schema: { $ref: '#/components/schemas/ReviewComment' } /reviews/{reviewId}/decision: post: tags: [Reviews] operationId: decideReview summary: Record a review decision description: > `accept` publishes the version (state → `published`, previous published version → `superseded`) and fires `problem.published` / `course.published` plus `review.completed` webhooks. `reject` closes the review; `request_changes` returns the version to the author in `draft`. Reviewer reputation is awarded asynchronously. x-permissions: { authentication: required, roles: [reviewer, moderator, admin], object: "claiming reviewer or moderator" } x-rate-limit: { bucket: write, limit: 30/min } parameters: - $ref: '#/components/parameters/ReviewId' requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ReviewDecision' } responses: '200': description: Decision recorded. content: application/json: schema: { $ref: '#/components/schemas/Review' } '409': description: Review already decided. content: application/problem+json: schema: { $ref: '#/components/schemas/Error' } # ────────────────────────────────────────────────────────── Taxonomy ── /tags: get: tags: [Taxonomy] operationId: listTags summary: List tags x-permissions: { authentication: none, roles: [], object: null } parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/PageSize' - name: q in: query schema: { type: string, maxLength: 64 } - name: ordering in: query schema: { type: string, enum: [name, usage_count, -usage_count], default: -usage_count } responses: '200': description: Tags. content: application/json: schema: allOf: - $ref: '#/components/schemas/PageMeta' - type: object properties: results: type: array items: { $ref: '#/components/schemas/Tag' } post: tags: [Taxonomy] operationId: createTag summary: Create a tag x-permissions: { authentication: required, roles: [moderator, admin], object: null } x-rate-limit: { bucket: write, limit: 30/min } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/TagCreate' } responses: '201': description: Created. content: application/json: schema: { $ref: '#/components/schemas/Tag' } '409': description: Slug already exists. content: application/problem+json: schema: { $ref: '#/components/schemas/Error' } /tags/{slug}: get: tags: [Taxonomy] operationId: getTag summary: Get a tag x-permissions: { authentication: none, roles: [], object: null } parameters: - name: slug in: path required: true schema: { type: string } responses: '200': description: Tag. content: application/json: schema: { $ref: '#/components/schemas/Tag' } '404': { $ref: '#/components/responses/NotFound' } /topics: get: tags: [Taxonomy] operationId: listTopics summary: List the topic tree x-permissions: { authentication: none, roles: [], object: null } parameters: - name: parent in: query description: Slug of parent topic; omit for root topics. schema: { type: string } - name: depth in: query schema: { type: integer, minimum: 1, maximum: 3, default: 1 } responses: '200': description: Topic tree (nested up to `depth`). content: application/json: schema: type: object properties: results: type: array items: { $ref: '#/components/schemas/Topic' } /topics/{slug}: get: tags: [Taxonomy] operationId: getTopic summary: Get a topic with children and content counts x-permissions: { authentication: none, roles: [], object: null } parameters: - name: slug in: path required: true schema: { type: string } responses: '200': description: Topic. content: application/json: schema: { $ref: '#/components/schemas/Topic' } '404': { $ref: '#/components/responses/NotFound' } # ──────────────────────────────────────────────────────────── Search ── /search: get: tags: [Search] operationId: search summary: Full-text and faceted search over published content description: > Backed by Meilisearch. Searches problems, courses, lessons, and tags. Only published content is indexed. Facet counts are returned for topic, difficulty, problem_type, and tags. x-permissions: { authentication: optional, roles: [], object: null } x-rate-limit: { bucket: search, limit: 30/min } parameters: - name: q in: query required: true schema: { type: string, minLength: 1, maxLength: 256 } - name: type in: query schema: type: array items: { type: string, enum: [problem, course, lesson, tag] } style: form explode: false - name: topic in: query schema: { type: string } - name: difficulty in: query schema: { type: integer, minimum: 1, maximum: 5 } - name: tags in: query schema: { type: array, items: { type: string } } style: form explode: false - name: author in: query schema: { type: string } - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/PageSize' responses: '200': description: Ranked results with facets. content: application/json: schema: { $ref: '#/components/schemas/SearchResponse' } '429': { $ref: '#/components/responses/RateLimited' } # ──────────────────────────────────────────────────────── Moderation ── /reports: get: tags: [Moderation] operationId: listReports summary: List reports (moderation queue) x-permissions: { authentication: required, roles: [moderator, admin], object: null } parameters: - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/PageSize' - name: status in: query schema: { type: string, enum: [open, triaged, resolved, dismissed] } - name: reason in: query schema: { $ref: '#/components/schemas/ReportReason' } responses: '200': description: Reports. content: application/json: schema: allOf: - $ref: '#/components/schemas/PageMeta' - type: object properties: results: type: array items: { $ref: '#/components/schemas/Report' } '403': { $ref: '#/components/responses/Forbidden' } post: tags: [Moderation] operationId: createReport summary: Report content, a comment, or a user x-permissions: { authentication: required, roles: [], object: null } x-rate-limit: { bucket: write, limit: 10/min } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ReportCreate' } responses: '201': description: Report filed; fires `report.created` webhook to moderation endpoints. content: application/json: schema: { $ref: '#/components/schemas/Report' } /reports/{reportId}/resolve: post: tags: [Moderation] operationId: resolveReport summary: Resolve or dismiss a report with an action x-permissions: { authentication: required, roles: [moderator, admin], object: null } x-rate-limit: { bucket: write, limit: 60/min } parameters: - name: reportId in: path required: true schema: { type: string, format: uuid } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/ReportResolution' } responses: '200': description: Resolved; audit-logged. content: application/json: schema: { $ref: '#/components/schemas/Report' } '409': description: Already resolved. content: application/problem+json: schema: { $ref: '#/components/schemas/Error' } # ─────────────────────────────────────────────────────────────── OER ── /oer/courses/{courseId}/export: get: tags: [OER] operationId: exportCourse summary: Export a published course version as a versioned OER package description: > Returns a ZIP archive containing `package.json` (manifest conforming to `schemas/v1/oer-package.json`), all lesson/problem content documents, and referenced media assets. Use `format=manifest` for the JSON manifest only (low-bandwidth mode). x-permissions: { authentication: optional, roles: [], object: "published versions only" } x-rate-limit: { bucket: oer, limit: 6/hour } parameters: - $ref: '#/components/parameters/CourseId' - name: version in: query description: Version number; defaults to current published version. schema: { type: integer, minimum: 1 } - name: format in: query schema: { type: string, enum: [zip, manifest], default: zip } responses: '200': description: OER package or manifest. content: application/zip: schema: { type: string, format: binary } application/json: schema: { $ref: '#/components/schemas/OERManifest' } '404': { $ref: '#/components/responses/NotFound' } '429': { $ref: '#/components/responses/RateLimited' } /oer/imports: post: tags: [OER] operationId: importPackage summary: Import an OER package as a draft course description: > Accepts a ZIP package or a JSON manifest with remote asset URLs. Validation (schema suite + license compatibility + attribution chain) runs asynchronously; poll the returned job or subscribe to the `oer.import.completed` webhook. Imported content always enters as `draft` and must pass review before publication. x-permissions: { authentication: required, roles: [contributor, reviewer, moderator, admin], object: null } x-rate-limit: { bucket: oer, limit: 6/hour } requestBody: required: true content: application/zip: schema: { type: string, format: binary } application/json: schema: { $ref: '#/components/schemas/OERManifest' } responses: '202': description: Import job accepted. content: application/json: schema: { $ref: '#/components/schemas/OERImportJob' } '400': { $ref: '#/components/responses/ValidationError' } '413': description: Package exceeds the 256 MiB limit. content: application/problem+json: schema: { $ref: '#/components/schemas/Error' } /oer/imports/{jobId}: get: tags: [OER] operationId: getImportJob summary: Poll an OER import job x-permissions: { authentication: required, roles: [], object: "job owner or admin" } parameters: - name: jobId in: path required: true schema: { type: string, format: uuid } responses: '200': description: Job state. content: application/json: schema: { $ref: '#/components/schemas/OERImportJob' } '404': { $ref: '#/components/responses/NotFound' } # ─────────────────────────────────────────────────────────── Webhooks ── /webhooks: get: tags: [Webhooks] operationId: listWebhookSubscriptions summary: List the caller's webhook subscriptions x-permissions: { authentication: required, roles: [], object: self } responses: '200': description: Subscriptions. content: application/json: schema: type: object properties: results: type: array items: { $ref: '#/components/schemas/WebhookSubscription' } post: tags: [Webhooks] operationId: createWebhookSubscription summary: Create a webhook subscription description: > Endpoints must be HTTPS. Deliveries are signed with `X-FablePool-Signature: t=,v1=` and retried with exponential backoff (max 8 attempts over ~24 h). Subscriptions are auto-disabled after 7 consecutive days of failure. Moderation events (`report.created`) require the moderator role. x-permissions: { authentication: required, roles: [], object: "moderator role required for moderation events" } x-rate-limit: { bucket: write, limit: 10/min } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/WebhookSubscriptionCreate' } responses: '201': description: Created; secret returned once. content: application/json: schema: { $ref: '#/components/schemas/WebhookSubscriptionWithSecret' } '400': { $ref: '#/components/responses/ValidationError' } /webhooks/{subscriptionId}: parameters: - name: subscriptionId in: path required: true schema: { type: string, format: uuid } patch: tags: [Webhooks] operationId: updateWebhookSubscription summary: Update events, URL, or active state x-permissions: { authentication: required, roles: [], object: "subscription owner" } requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/WebhookSubscriptionUpdate' } responses: '200': description: Updated. content: application/json: schema: { $ref: '#/components/schemas/WebhookSubscription' } delete: tags: [Webhooks] operationId: deleteWebhookSubscription summary: Delete a subscription x-permissions: { authentication: required, roles: [], object: "subscription owner" } responses: '204': { description: Deleted. } /webhooks/{subscriptionId}/rotate-secret: post: tags: [Webhooks] operationId: rotateWebhookSecret summary: Rotate the signing secret description: Old secret remains valid for 24 hours to allow zero-downtime rotation. x-permissions: { authentication: required, roles: [], object: "subscription owner" } parameters: - name: subscriptionId in: path required: true schema: { type: string, format: uuid } responses: '200': description: New secret returned once. content: application/json: schema: { $ref: '#/components/schemas/WebhookSubscriptionWithSecret' } # ──────────────────────────────────────────────────── Outbound webhooks ── webhooks: problem.published: post: summary: A problem version was accepted and published requestBody: content: application/json: schema: { $ref: '#/components/schemas/EventProblemPublished' } responses: '200': { description: Acknowledge with any 2xx within 10 s. } course.published: post: summary: A course version was accepted and published requestBody: content: application/json: schema: { $ref: '#/components/schemas/EventCoursePublished' } responses: '200': { description: Acknowledge with any 2xx within 10 s. } review.requested: post: summary: A version was submitted and needs review requestBody: content: application/json: schema: { $ref: '#/components/schemas/EventReviewRequested' } responses: '200': { description: Acknowledge with any 2xx within 10 s. } review.completed: post: summary: A review reached a decision requestBody: content: application/json: schema: { $ref: '#/components/schemas/EventReviewCompleted' } responses: '200': { description: Acknowledge with any 2xx within 10 s. } attempt.graded: post: summary: An asynchronously graded attempt (code/proof) finished grading requestBody: content: application/json: schema: { $ref: '#/components/schemas/EventAttemptGraded' } responses: '200': { description: Acknowledge with any 2xx within 10 s. } report.created: post: summary: A new moderation report was filed (moderator subscribers only) requestBody: content: application/json: schema: { $ref: '#/components/schemas/EventReportCreated' } responses: '200': { description: Acknowledge with any 2xx within 10 s. } oer.import.completed: post: summary: An OER import job finished (succeeded or failed) requestBody: content: application/json: schema: { $ref: '#/components/schemas/EventOERImportCompleted' } responses: '200': { description: Acknowledge with any 2xx within 10 s. } components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT description: Session JWT issued by the auth service (60 min, refreshable). apiKey: type: apiKey in: header name: X-API-Key description: Long-lived API key for server integrations; scoped per key. parameters: Cursor: name: cursor in: query description: Opaque pagination cursor from a previous response's `next_cursor`. schema: { type: string } PageSize: name: page_size in: query schema: { type: integer, minimum: 1, maximum: 100, default: 20 } ProblemId: name: problemId in: path required: true schema: { type: string, format: uuid } CourseId: name: courseId in: path required: true schema: { type: string, format: uuid } ReviewId: name: reviewId in: path required: true schema: { type: string, format: uuid } NotificationId: name: notificationId in: path required: true schema: { type: string, format: uuid } VersionNumber: name: versionNumber in: path required: true schema: { type: integer, minimum: 1 } headers: RateLimitBundle: description: Rate limit state for the caller's bucket. schema: { type: string } responses: NotFound: description: Resource not found (or not visible to the caller). content: application/problem+json: schema: { $ref: '#/components/schemas/Error' } Unauthorized: description: Missing or invalid credentials. content: application/problem+json: schema: { $ref: '#/components/schemas/Error' } Forbidden: description: Authenticated but not permitted. content: application/problem+json: schema: { $ref: '#/components/schemas/Error' } ValidationError: description: Request body or parameters failed validation. content: application/problem+json: schema: { $ref: '#/components/schemas/Error' } RateLimited: description: Rate limit exceeded for the relevant bucket. headers: Retry-After: schema: { type: integer } description: Seconds until the bucket refills. content: application/problem+json: schema: { $ref: '#/components/schemas/Error' } schemas: Error: type: object description: RFC 9457 problem details. required: [type, title, status] properties: type: { type: string, format: uri, default: 'about:blank' } title: { type: string } status: { type: integer } detail: { type: string } instance: { type: string, format: uri } errors: type: object description: Field-level validation messages. additionalProperties: type: array items: { type: string } PageMeta: type: object required: [next_cursor, has_more] properties: next_cursor: type: [string, 'null'] description: Pass as `cursor` to fetch the next page; null on the last page. has_more: { type: boolean } total_estimate: type: [integer, 'null'] description: Approximate total when cheaply available; null otherwise. ContentStatus: type: string enum: [draft, submitted, in_review, accepted, rejected, published, superseded] ProblemType: type: string enum: [multiple_choice, numeric, expression, free_text, code, ordering, matching, widget] License: type: string enum: [CC-BY-SA-4.0, CC-BY-4.0, CC0-1.0] default: CC-BY-SA-4.0 ContentDocument: type: object description: > Structured content document conforming to `schemas/v1/content-document.json`. Block-based: paragraphs, headings, math (KaTeX-compatible LaTeX), code, images, callouts, and sandboxed widget references. Widgets reference a manifest validated by `schemas/v1/widget-manifest.json` and are rendered in sandboxed iframes — never inline. required: [schema, blocks] properties: schema: type: string const: 'https://fablepool.org/schemas/v1/content-document.json' locale: { type: string, default: en } blocks: type: array items: { type: object } Attribution: type: object description: CC BY-SA attribution chain entry, oldest ancestor first. required: [author, title, url, license] properties: author: { type: string } title: { type: string } url: { type: string, format: uri } license: { $ref: '#/components/schemas/License' } modified: { type: boolean, default: true } UserSummary: type: object required: [id, username] properties: id: { type: string, format: uuid } username: { type: string } display_name: { type: [string, 'null'] } avatar_url: { type: [string, 'null'], format: uri } reputation: { type: integer } roles: type: array items: { type: string, enum: [learner, contributor, reviewer, moderator, admin] } UserDetail: allOf: - $ref: '#/components/schemas/UserSummary' - type: object properties: bio: { type: [string, 'null'] } created_at: { type: string, format: date-time } stats: type: object properties: problems_authored: { type: integer } courses_authored: { type: integer } reviews_completed: { type: integer } problems_solved: { type: integer } Me: allOf: - $ref: '#/components/schemas/UserDetail' - type: object properties: email: { type: string, format: email } locale: { type: string } settings: type: object properties: email_digest: { type: string, enum: [daily, weekly, never] } low_bandwidth_mode: { type: boolean } spoiler_protection: { type: boolean } MeUpdate: type: object minProperties: 1 properties: display_name: { type: [string, 'null'], maxLength: 64 } bio: { type: [string, 'null'], maxLength: 2000 } locale: { type: string } settings: { type: object } Notification: type: object required: [id, kind, created_at, read] properties: id: { type: string, format: uuid } kind: type: string enum: [review_requested, review_decided, comment_reply, mention, content_published, report_resolved, streak_reminder, system] title: { type: string } body: { type: [string, 'null'] } target_url: { type: [string, 'null'], format: uri-reference } read: { type: boolean } created_at: { type: string, format: date-time } Bookmark: type: object required: [id, target_type, target_id, created_at] properties: id: { type: string, format: uuid } target_type: { type: string, enum: [problem, course, lesson] } target_id: { type: string, format: uuid } title: { type: string } note: { type: [string, 'null'], maxLength: 500 } created_at: { type: string, format: date-time } BookmarkCreate: type: object required: [target_type, target_id] properties: target_type: { type: string, enum: [problem, course, lesson] } target_id: { type: string, format: uuid } note: { type: [string, 'null'], maxLength: 500 } ProgressOverview: type: object properties: problems_attempted: { type: integer } problems_solved: { type: integer } courses_enrolled: { type: integer } courses_completed: { type: integer } lessons_completed: { type: integer } streak: { $ref: '#/components/schemas/StreakInfo' } recent_activity: type: array items: type: object properties: kind: { type: string, enum: [attempt, lesson_completed, course_completed] } target_id: { type: string, format: uuid } title: { type: string } occurred_at: { type: string, format: date-time } CourseProgress: type: object required: [course_id, percent_complete, lessons] properties: course_id: { type: string, format: uuid } enrolled_version: { type: integer } percent_complete: { type: number, minimum: 0, maximum: 100 } completed_at: { type: [string, 'null'], format: date-time } lessons: type: array items: type: object required: [lesson_id, state] properties: lesson_id: { type: string, format: uuid } state: { type: string, enum: [not_started, in_progress, completed] } score: { type: [number, 'null'] } completed_at: { type: [string, 'null'], format: date-time } RepetitionItem: type: object required: [problem, due_at] properties: problem: { $ref: '#/components/schemas/ProblemSummary' } due_at: { type: string, format: date-time } interval_days: { type: number } ease_factor: { type: number } repetitions: { type: integer } last_reviewed_at: { type: [string, 'null'], format: date-time } StreakInfo: type: object required: [current_days, longest_days] properties: current_days: { type: integer } longest_days: { type: integer } last_active_date: { type: [string, 'null'], format: date } timezone: { type: string, default: UTC } Tag: type: object required: [id, slug, name] properties: id: { type: string, format: uuid } slug: { type: string } name: { type: string } description: { type: [string, 'null'] } usage_count: { type: integer } TagCreate: type: object required: [name] properties: name: { type: string, maxLength: 64 } slug: { type: string, pattern: '^[a-z0-9-]{2,64}$' } description: { type: [string, 'null'], maxLength: 500 } Topic: type: object required: [id, slug, name] properties: id: { type: string, format: uuid } slug: { type: string } name: { type: string } description: { type: [string, 'null'] } parent_id: { type: [string, 'null'], format: uuid } problem_count: { type: integer } course_count: { type: integer } children: type: array items: { $ref: '#/components/schemas/Topic' } ProblemSummary: type: object required: [id, slug, title, problem_type, difficulty, status] properties: id: { type: string, format: uuid } slug: { type: string } title: { type: string } problem_type: { $ref: '#/components/schemas/ProblemType' } difficulty: { type: integer, minimum: 1, maximum: 5 } status: { $ref: '#/components/schemas/ContentStatus' } topics: type: array items: { type: string } tags: type: array items: { type: string } author: { $ref: '#/components/schemas/UserSummary' } published_version: { type: [integer, 'null'] } forked_from: { type: [string, 'null'], format: uuid } license: { $ref: '#/components/schemas/License' } stats: type: object properties: attempts: { type: integer } solve_rate: { type: [number, 'null'], minimum: 0, maximum: 1 } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } ProblemDetail: allOf: - $ref: '#/components/schemas/ProblemSummary' - type: object properties: content: { $ref: '#/components/schemas/ContentDocument' } answer_spec: type: object description: > Presentation-only answer specification (choices, input format, widget config). Correct answers, tolerances, and graders are never exposed through the public API. prerequisites: type: array items: { type: string, format: uuid } attribution: type: array items: { $ref: '#/components/schemas/Attribution' } hints_count: { type: integer } solutions_count: { type: integer } ProblemCreate: type: object required: [title, problem_type, difficulty, content, answer_spec, topics] properties: title: { type: string, maxLength: 200 } problem_type: { $ref: '#/components/schemas/ProblemType' } difficulty: { type: integer, minimum: 1, maximum: 5 } content: { $ref: '#/components/schemas/ContentDocument' } answer_spec: type: object description: Full answer specification including the key; validated against `schemas/v1/problem-content.json`. topics: type: array minItems: 1 items: { type: string } tags: type: array items: { type: string } prerequisites: type: array items: { type: string, format: uuid } hints: type: array items: { $ref: '#/components/schemas/HintCreate' } license: { $ref: '#/components/schemas/License' } ProblemMetaUpdate: type: object minProperties: 1 properties: difficulty: { type: integer, minimum: 1, maximum: 5 } topics: { type: array, items: { type: string } } tags: { type: array, items: { type: string } } prerequisites: { type: array, items: { type: string, format: uuid } } ProblemVersion: type: object required: [id, version_number, status, created_at] properties: id: { type: string, format: uuid } version_number: { type: integer, minimum: 1 } status: { $ref: '#/components/schemas/ContentStatus' } title: { type: string } content: { $ref: '#/components/schemas/ContentDocument' } changelog: { type: [string, 'null'] } created_by: { $ref: '#/components/schemas/UserSummary' } review_id: { type: [string, 'null'], format: uuid } created_at: { type: string, format: date-time } published_at: { type: [string, 'null'], format: date-time } ProblemVersionCreate: type: object required: [content, answer_spec, changelog] properties: title: { type: string, maxLength: 200 } content: { $ref: '#/components/schemas/ContentDocument' } answer_spec: { type: object } changelog: { type: string, minLength: 5, maxLength: 2000 } hints: type: array items: { $ref: '#/components/schemas/HintCreate' } Hint: type: object required: [id, order] properties: id: { type: string, format: uuid } order: { type: integer, minimum: 1 } content: { $ref: '#/components/schemas/ContentDocument' } penalty: type: number minimum: 0 maximum: 1 description: Score multiplier penalty applied if used. HintCreate: type: object required: [order, content] properties: order: { type: integer, minimum: 1 } content: { $ref: '#/components/schemas/ContentDocument' } penalty: { type: number, minimum: 0, maximum: 1, default: 0.1 } Solution: type: object required: [id, content, author] properties: id: { type: string, format: uuid } content: { $ref: '#/components/schemas/ContentDocument' } author: { $ref: '#/components/schemas/UserSummary' } is_official: { type: boolean } score: { type: integer, description: Net vote score. } my_vote: { type: [integer, 'null'], enum: [-1, 1, null] } created_at: { type: string, format: date-time } AttemptCreate: type: object required: [answer] properties: version_number: type: integer description: Defaults to the current published version. answer: description: Shape depends on `problem_type`. oneOf: - type: object title: MultipleChoiceAnswer required: [selected] properties: selected: type: array items: { type: string } description: Choice IDs (single-element for single-select). - type: object title: NumericAnswer required: [value] properties: value: { type: number } unit: { type: [string, 'null'] } - type: object title: ExpressionAnswer required: [latex] properties: latex: { type: string, maxLength: 2000 } - type: object title: FreeTextAnswer required: [text] properties: text: { type: string, maxLength: 50000 } - type: object title: CodeAnswer required: [language, source] properties: language: { type: string, enum: [python, javascript, rust, c, cpp, java] } source: { type: string, maxLength: 100000 } - type: object title: OrderingAnswer required: [order] properties: order: type: array items: { type: string } - type: object title: MatchingAnswer required: [pairs] properties: pairs: type: array items: type: object required: [left, right] properties: left: { type: string } right: { type: string } - type: object title: WidgetAnswer required: [state] properties: state: { type: object } hints_used: type: array items: { type: string, format: uuid } ProblemAttempt: type: object required: [id, problem_id, grading, created_at] properties: id: { type: string, format: uuid } problem_id: { type: string, format: uuid } version_number: { type: integer } attempt_number: { type: integer, minimum: 1 } grading: { type: string, enum: [graded, pending, failed] } is_correct: { type: [boolean, 'null'] } score: { type: [number, 'null'], minimum: 0, maximum: 1 } feedback: type: [object, 'null'] description: Structured feedback (per-choice correctness, test results for code, etc.). hints_used: type: array items: { type: string, format: uuid } created_at: { type: string, format: date-time } graded_at: { type: [string, 'null'], format: date-time } CourseSummary: type: object required: [id, slug, title, status] properties: id: { type: string, format: uuid } slug: { type: string } title: { type: string } summary: { type: [string, 'null'], maxLength: 500 } status: { $ref: '#/components/schemas/ContentStatus' } difficulty: { type: integer, minimum: 1, maximum: 5 } topics: { type: array, items: { type: string } } tags: { type: array, items: { type: string } } author: { $ref: '#/components/schemas/UserSummary' } published_version: { type: [integer, 'null'] } forked_from: { type: [string, 'null'], format: uuid } license: { $ref: '#/components/schemas/License' } stats: type: object properties: enrollments: { type: integer } completion_rate: { type: [number, 'null'] } estimated_hours: { type: [number, 'null'] } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } CourseDetail: allOf: - $ref: '#/components/schemas/CourseSummary' - type: object properties: description: { $ref: '#/components/schemas/ContentDocument' } attribution: type: array items: { $ref: '#/components/schemas/Attribution' } modules: type: array items: { $ref: '#/components/schemas/Module' } my_enrollment: { oneOf: [{ $ref: '#/components/schemas/Enrollment' }, { type: 'null' }] } CourseCreate: type: object required: [title, summary, topics, difficulty] properties: title: { type: string, maxLength: 200 } summary: { type: string, maxLength: 500 } description: { $ref: '#/components/schemas/ContentDocument' } topics: { type: array, minItems: 1, items: { type: string } } tags: { type: array, items: { type: string } } difficulty: { type: integer, minimum: 1, maximum: 5 } license: { $ref: '#/components/schemas/License' } CourseMetaUpdate: type: object minProperties: 1 properties: summary: { type: string, maxLength: 500 } topics: { type: array, items: { type: string } } tags: { type: array, items: { type: string } } difficulty: { type: integer, minimum: 1, maximum: 5 } CourseVersion: type: object required: [id, version_number, status, created_at] properties: id: { type: string, format: uuid } version_number: { type: integer, minimum: 1 } status: { $ref: '#/components/schemas/ContentStatus' } title: { type: string } changelog: { type: [string, 'null'] } modules: type: array items: { $ref: '#/components/schemas/Module' } created_by: { $ref: '#/components/schemas/UserSummary' } review_id: { type: [string, 'null'], format: uuid } created_at: { type: string, format: date-time } published_at: { type: [string, 'null'], format: date-time } CourseVersionCreate: type: object required: [changelog, modules] properties: title: { type: string, maxLength: 200 } description: { $ref: '#/components/schemas/ContentDocument' } changelog: { type: string, minLength: 5, maxLength: 2000 } modules: type: array minItems: 1 items: type: object required: [title, lessons] properties: id: type: [string, 'null'] format: uuid description: Existing module ID to carry over; null to create new. title: { type: string, maxLength: 200 } summary: { type: [string, 'null'], maxLength: 500 } lessons: type: array items: type: object required: [title, kind] properties: id: { type: [string, 'null'], format: uuid } title: { type: string, maxLength: 200 } kind: { type: string, enum: [lesson, problem_set, quiz, final_project] } content: { $ref: '#/components/schemas/ContentDocument' } problem_ids: type: array items: { type: string, format: uuid } description: Published problems referenced by problem sets/quizzes. Module: type: object required: [id, title, order, lessons] properties: id: { type: string, format: uuid } title: { type: string } summary: { type: [string, 'null'] } order: { type: integer, minimum: 1 } lessons: type: array items: { $ref: '#/components/schemas/LessonSummary' } LessonSummary: type: object required: [id, title, kind, order] properties: id: { type: string, format: uuid } title: { type: string } kind: { type: string, enum: [lesson, problem_set, quiz, final_project] } order: { type: integer, minimum: 1 } estimated_minutes: { type: [integer, 'null'] } problem_count: { type: integer } Lesson: allOf: - $ref: '#/components/schemas/LessonSummary' - type: object properties: content: { $ref: '#/components/schemas/ContentDocument' } problems: type: array items: { $ref: '#/components/schemas/ProblemSummary' } Enrollment: type: object required: [id, course_id, enrolled_at] properties: id: { type: string, format: uuid } course_id: { type: string, format: uuid } enrolled_version: { type: integer } active: { type: boolean } enrolled_at: { type: string, format: date-time } completed_at: { type: [string, 'null'], format: date-time } Review: type: object required: [id, content_type, content_id, version_number, state, created_at] properties: id: { type: string, format: uuid } content_type: { type: string, enum: [problem, course] } content_id: { type: string, format: uuid } content_title: { type: string } version_number: { type: integer } state: { type: string, enum: [open, claimed, changes_requested, decided] } decision: { type: [string, 'null'], enum: [accept, reject, request_changes, null] } decision_rationale: { type: [string, 'null'] } submitter: { $ref: '#/components/schemas/UserSummary' } reviewer: { oneOf: [{ $ref: '#/components/schemas/UserSummary' }, { type: 'null' }] } checklist: type: object description: Reviewer rubric (correctness, clarity, accessibility, licensing, originality). properties: correctness: { type: [boolean, 'null'] } clarity: { type: [boolean, 'null'] } accessibility: { type: [boolean, 'null'] } licensing: { type: [boolean, 'null'] } originality: { type: [boolean, 'null'] } created_at: { type: string, format: date-time } decided_at: { type: [string, 'null'], format: date-time } ReviewComment: type: object required: [id, author, body, created_at] properties: id: { type: string, format: uuid } author: { $ref: '#/components/schemas/UserSummary' } body: { type: string } block_anchor: type: [string, 'null'] description: ID of the content block the comment is anchored to. parent_id: { type: [string, 'null'], format: uuid } resolved: { type: boolean } created_at: { type: string, format: date-time } ReviewCommentCreate: type: object required: [body] properties: body: { type: string, minLength: 1, maxLength: 10000 } block_anchor: { type: [string, 'null'] } parent_id: { type: [string, 'null'], format: uuid } ReviewDecision: type: object required: [decision, rationale] properties: decision: { type: string, enum: [accept, reject, request_changes] } rationale: { type: string, minLength: 10, maxLength: 5000 } checklist: type: object properties: correctness: { type: boolean } clarity: { type: boolean } accessibility: { type: boolean } licensing: { type: boolean } originality: { type: boolean } DiscussionThread: type: object required: [id, title, author, created_at] properties: id: { type: string, format: uuid } title: { type: string } author: { $ref: '#/components/schemas/UserSummary' } target_type: { type: string, enum: [problem, course] } target_id: { type: string, format: uuid } contains_spoilers: { type: boolean } locked: { type: boolean } pinned: { type: boolean } comment_count: { type: integer } created_at: { type: string, format: date-time } last_activity_at: { type: string, format: date-time } ThreadCreate: type: object required: [title, body] properties: title: { type: string, minLength: 3, maxLength: 200 } body: { type: string, minLength: 1, maxLength: 20000 } contains_spoilers: { type: boolean, default: false } DiscussionComment: type: object required: [id, author, body, created_at] properties: id: { type: string, format: uuid } author: { $ref: '#/components/schemas/UserSummary' } body: { type: string } parent_id: { type: [string, 'null'], format: uuid } score: { type: integer } my_vote: { type: [integer, 'null'], enum: [-1, 1, null] } deleted: { type: boolean, description: Soft-deleted comments keep thread structure with redacted body. } created_at: { type: string, format: date-time } edited_at: { type: [string, 'null'], format: date-time } CommentCreate: type: object required: [body] properties: body: { type: string, minLength: 1, maxLength: 20000 } parent_id: { type: [string, 'null'], format: uuid } VoteCreate: type: object required: [target_type, target_id, value] properties: target_type: { type: string, enum: [comment, solution] } target_id: { type: string, format: uuid } value: type: integer enum: [-1, 0, 1] description: 0 retracts an existing vote. VoteResult: type: object required: [target_id, score, my_vote] properties: target_id: { type: string, format: uuid } score: { type: integer } my_vote: { type: [integer, 'null'], enum: [-1, 1, null] } ReportReason: type: string enum: [spam, plagiarism, incorrect_content, inappropriate, harassment, license_violation, security, other] Report: type: object required: [id, reason, status, created_at] properties: id: { type: string, format: uuid } reason: { $ref: '#/components/schemas/ReportReason' } status: { type: string, enum: [open, triaged, resolved, dismissed] } target_type: { type: string, enum: [problem, course, comment, solution, user] } target_id: { type: string, format: uuid } detail: { type: [string, 'null'] } reporter: oneOf: [{ $ref: '#/components/schemas/UserSummary' }, { type: 'null' }] description: Visible to moderators only; null for reporters viewing their own reports of others. resolution: { type: [string, 'null'] } resolved_by: { oneOf: [{ $ref: '#/components/schemas/UserSummary' }, { type: 'null' }] } created_at: { type: string, format: date-time } resolved_at: { type: [string, 'null'], format: date-time } ReportCreate: type: object required: [reason, target_type, target_id] properties: reason: { $ref: '#/components/schemas/ReportReason' } target_type: { type: string, enum: [problem, course, comment, solution, user] } target_id: { type: string, format: uuid } detail: { type: [string, 'null'], maxLength: 5000 } ReportResolution: type: object required: [action, note] properties: action: type: string enum: [dismiss, content_removed, content_edited, user_warned, user_suspended, escalated] note: { type: string, minLength: 5, maxLength: 5000 } OERManifest: type: object description: Conforms to `schemas/v1/oer-package.json`. required: [schema, package, course] properties: schema: type: string const: 'https://fablepool.org/schemas/v1/oer-package.json' package: type: object required: [id, version, exported_at, generator] properties: id: { type: string, format: uuid } version: { type: integer } exported_at: { type: string, format: date-time } generator: { type: string } checksum_sha256: { type: [string, 'null'] } course: { type: object } problems: { type: array, items: { type: object } } assets: type: array items: type: object required: [path, sha256, media_type] properties: path: { type: string } url: { type: [string, 'null'], format: uri } sha256: { type: string } media_type: { type: string } attribution: type: array items: { $ref: '#/components/schemas/Attribution' } OERImportJob: type: object required: [id, state, created_at] properties: id: { type: string, format: uuid } state: { type: string, enum: [queued, validating, importing, succeeded, failed] } course_id: type: [string, 'null'] format: uuid description: Set when the draft course is created. errors: type: array items: type: object properties: path: { type: string } message: { type: string } created_at: { type: string, format: date-time } finished_at: { type: [string, 'null'], format: date-time } SearchResponse: allOf: - $ref: '#/components/schemas/PageMeta' - type: object required: [results, facets, processing_time_ms] properties: processing_time_ms: { type: integer } results: type: array items: type: object required: [type, id, title, url] properties: type: { type: string, enum: [problem, course, lesson, tag] } id: { type: string, format: uuid } title: { type: string } snippet: { type: [string, 'null'], description: Highlighted match excerpt. } url: { type: string, format: uri-reference } difficulty: { type: [integer, 'null'] } topics: { type: array, items: { type: string } } facets: type: object additionalProperties: type: object additionalProperties: { type: integer } WebhookEventName: type: string enum: [problem.published, course.published, review.requested, review.completed, attempt.graded, report.created, oer.import.completed] WebhookSubscription: type: object required: [id, url, events, active, created_at] properties: id: { type: string, format: uuid } url: { type: string, format: uri } events: type: array items: { $ref: '#/components/schemas/WebhookEventName' } active: { type: boolean } disabled_reason: { type: [string, 'null'] } last_delivery_at: { type: [string, 'null'], format: date-time } consecutive_failures: { type: integer } created_at: { type: string, format: date-time } WebhookSubscriptionWithSecret: allOf: - $ref: '#/components/schemas/WebhookSubscription' - type: object required: [secret] properties: secret: type: string description: HMAC-SHA256 signing secret. Shown only at creation/rotation. WebhookSubscriptionCreate: type: object required: [url, events] properties: url: { type: string, format: uri, pattern: '^https://' } events: type: array minItems: 1 items: { $ref: '#/components/schemas/WebhookEventName' } WebhookSubscriptionUpdate: type: object minProperties: 1 properties: url: { type: string, format: uri, pattern: '^https://' } events: type: array minItems: 1 items: { $ref: '#/components/schemas/WebhookEventName' } active: { type: boolean } WebhookEnvelope: type: object required: [id, event, created_at, data] properties: id: { type: string, format: uuid, description: Delivery ID; use for idempotent processing. } event: { $ref: '#/components/schemas/WebhookEventName' } created_at: { type: string, format: date-time } api_version: { type: string, const: v1 } data: { type: object } EventProblemPublished: allOf: - $ref: '#/components/schemas/WebhookEnvelope' - type: object properties: data: type: object required: [problem_id, version_number] properties: problem_id: { type: string, format: uuid } version_number: { type: integer } title: { type: string } author_username: { type: string } EventCoursePublished: allOf: - $ref: '#/components/schemas/WebhookEnvelope' - type: object properties: data: type: object required: [course_id, version_number] properties: course_id: { type: string, format: uuid } version_number: { type: integer } title: { type: string } author_username: { type: string } EventReviewRequested: allOf: - $ref: '#/components/schemas/WebhookEnvelope' - type: object properties: data: type: object required: [review_id, content_type, content_id, version_number] properties: review_id: { type: string, format: uuid } content_type: { type: string, enum: [problem, course] } content_id: { type: string, format: uuid } version_number: { type: integer } topics: { type: array, items: { type: string } } EventReviewCompleted: allOf: - $ref: '#/components/schemas/WebhookEnvelope' - type: object properties: data: type: object required: [review_id, decision] properties: review_id: { type: string, format: uuid } decision: { type: string, enum: [accept, reject, request_changes] } content_type: { type: string, enum: [problem, course] } content_id: { type: string, format: uuid } version_number: { type: integer } EventAttemptGraded: allOf: - $ref: '#/components/schemas/WebhookEnvelope' - type: object properties: data: type: object required: [attempt_id, problem_id, is_correct] properties: attempt_id: { type: string, format: uuid } problem_id: { type: string, format: uuid } user_id: { type: string, format: uuid } is_correct: { type: boolean } score: { type: number } EventReportCreated: allOf: - $ref: '#/components/schemas/WebhookEnvelope' - type: object properties: data: type: object required: [report_id, reason, target_type, target_id] properties: report_id: { type: string, format: uuid } reason: { $ref: '#/components/schemas/ReportReason' } target_type: { type: string } target_id: { type: string, format: uuid } EventOERImportCompleted: allOf: - $ref: '#/components/schemas/WebhookEnvelope' - type: object properties: data: type: object required: [job_id, state] properties: job_id: { type: string, format: uuid } state: { type: string, enum: [succeeded, failed] } course_id: { type: [string, 'null'], format: uuid }