openapi: "3.1.0"
info:
  title: "Triumph API"
  version: "1.0.0"
  summary: "Public API for the Triumph Praxis 5001 readiness tool: practice-question bank, subtest metadata and account sync."
  description: "Triumph is a free Praxis 5001 (Elementary Education: Multiple Subjects) study tool. This API exposes the public practice-question bank (969 original questions across four subtests), bank statistics, and the same account endpoints the web app uses.\n\n**Authentication tiers**\n\n- Anonymous: all `GET /api/v1/*` reads work with no credentials (public tier).\n- Scoped API key: send `X-API-Key: tri_live_...`. Create a free key with `POST /api/v1/keys`. Scopes: meta:read, questions:read, stats:read.\n- Bearer JWT: account endpoints (`/api/state`) use the token returned by `POST /api/login`.\n\nAll errors use one structured envelope: `{ \"error\": { \"code\", \"message\", \"hint\" }, \"status\" }`."
  contact:
    name: "Triumph support"
    email: "abc15531888397@gmail.com"
    url: "https://praxis5001.com/contact"
  termsOfService: "https://praxis5001.com/terms-of-service.md"
servers:
  -
    url: "https://praxis5001.com"
    description: "Production"
tags:
  -
    name: "public-data"
    description: "Read-only question bank endpoints (anonymous access allowed)"
  -
    name: "api-keys"
    description: "Self-serve scoped API key management"
  -
    name: "account"
    description: "Account creation, verification and cloud state sync used by the web app"
paths:
  /api/v1/health:
    get:
      tags:
        - "public-data"
      operationId: "getHealth"
      summary: "Service health check"
      description: "Returns service availability, version and the list of public endpoints. Suitable as an uptime probe."
      security: []
      responses:
        200:
          description: "Service is healthy."
          content:
            application/json:
              schema:
                type: "object"
                properties:
                  ok:
                    type: "boolean"
                    const: true
                  service:
                    type: "string"
                    const: "triumph-api"
                  version:
                    type: "string"
                  time:
                    type: "string"
                    format: "date-time"
                  endpoints:
                    type: "array"
                    items:
                      type: "string"
                  docs:
                    type: "string"
                    format: "uri"
  /api/v1/meta:
    get:
      tags:
        - "public-data"
      operationId: "getApiMeta"
      summary: "Product and subtest metadata"
      description: "Returns the four Praxis 5001 subtests with their content categories, per-subtest question counts and pointers to the developer documentation."
      security:
        -
          ApiKeyAuth:
            - "meta:read"
      responses:
        200:
          description: "Metadata about the question bank."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MetaResponse"
        400:
          description: "Bad request — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        401:
          description: "Unauthorized — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        404:
          description: "Not found — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        500:
          description: "Server error — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  /api/v1/stats:
    get:
      tags:
        - "public-data"
      operationId: "getQuestionStats"
      summary: "Question-bank statistics"
      description: "Counts of practice questions per subtest and per content category."
      security:
        -
          ApiKeyAuth:
            - "stats:read"
      responses:
        200:
          description: "Bank statistics."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/StatsResponse"
        400:
          description: "Bad request — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        401:
          description: "Unauthorized — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        403:
          description: "Forbidden — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        404:
          description: "Not found — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        500:
          description: "Server error — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  /api/v1/questions:
    get:
      tags:
        - "public-data"
      operationId: "listQuestions"
      summary: "List practice questions"
      description: "Paginated access to the full original question bank. Filter by subtest code, content category, or a case-insensitive substring search on the question text."
      security:
        -
          ApiKeyAuth:
            - "questions:read"
      parameters:
        -
          name: "subtest"
          in: "query"
          schema:
            type: "string"
            enum:
              - "5002"
              - "5003"
              - "5004"
              - "5005"
          description: "Subtest code filter"
        -
          name: "category"
          in: "query"
          schema:
            type: "string"
          description: "Case-insensitive exact category-name filter"
        -
          name: "search"
          in: "query"
          schema:
            type: "string"
          description: "Case-insensitive substring match on question text"
        -
          name: "limit"
          in: "query"
          schema:
            type: "integer"
            minimum: 1
            maximum: 100
            default: 20
          description: "Page size"
        -
          name: "offset"
          in: "query"
          schema:
            type: "integer"
            minimum: 0
            default: 0
          description: "Pagination offset"
      responses:
        200:
          description: "A page of questions."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/QuestionPage"
        400:
          description: "Bad request — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        401:
          description: "Unauthorized — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        403:
          description: "Forbidden — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        404:
          description: "Not found — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        500:
          description: "Server error — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  /api/v1/questions/random:
    get:
      tags:
        - "public-data"
      operationId: "getRandomQuestions"
      summary: "Random practice questions"
      description: "Draws random questions from the bank (optionally filtered). Useful for generating quick quizzes."
      security:
        -
          ApiKeyAuth:
            - "questions:read"
      parameters:
        -
          name: "count"
          in: "query"
          schema:
            type: "integer"
            minimum: 1
            maximum: 50
            default: 5
          description: "Number of questions to draw"
        -
          name: "subtest"
          in: "query"
          schema:
            type: "string"
            enum:
              - "5002"
              - "5003"
              - "5004"
              - "5005"
          description: "Subtest code filter"
        -
          name: "category"
          in: "query"
          schema:
            type: "string"
          description: "Category filter"
      responses:
        200:
          description: "Randomly drawn questions."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RandomQuestionSet"
        400:
          description: "Bad request — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        401:
          description: "Unauthorized — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        403:
          description: "Forbidden — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        404:
          description: "Not found — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        500:
          description: "Server error — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  /api/v1/keys:
    post:
      tags:
        - "api-keys"
      operationId: "createApiKey"
      summary: "Create a free scoped API key (self-serve)"
      description: "Issues a free-tier API key immediately — no account required. The key is returned once; store it securely. Rate limited to 10 keys per hour per IP."
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/CreateKeyRequest"
            examples:
              anonymous:
                value:
                  scopes:
                    - "questions:read"
              attributed:
                value:
                  email: "you@example.com"
                  scopes:
                    - "meta:read"
                    - "questions:read"
                    - "stats:read"
      responses:
        201:
          description: "Key created. The plaintext key appears exactly once in this response."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CreateKeyResponse"
        400:
          description: "Bad request — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        401:
          description: "Unauthorized — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        404:
          description: "Not found — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        429:
          description: "Too many requests — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        500:
          description: "Server error — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  /api/v1/keys/verify:
    get:
      tags:
        - "api-keys"
      operationId: "verifyApiKey"
      summary: "Verify an API key and inspect its scopes"
      description: "Confirms a key is valid and returns the scopes it holds. Pass the key as the X-API-Key header."
      security:
        -
          ApiKeyAuth:
            - "meta:read"
            - "questions:read"
            - "stats:read"
      responses:
        200:
          description: "Key is valid."
          content:
            application/json:
              schema:
                type: "object"
                properties:
                  ok:
                    type: "boolean"
                    const: true
                  valid:
                    type: "boolean"
                    const: true
                  scopes:
                    type: "array"
                    items:
                      type: "string"
                      enum:
                        - "meta:read"
                        - "questions:read"
                        - "stats:read"
                  tier:
                    type: "string"
                    const: "free"
                  created_at:
                    type: "string"
                    format: "date-time"
        400:
          description: "Bad request — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        401:
          description: "Unauthorized — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        404:
          description: "Not found — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        500:
          description: "Server error — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  /api/register:
    post:
      tags:
        - "account"
      operationId: "registerAccount"
      summary: "Register an account"
      description: "Creates an unverified account and emails a 6-digit verification code. Responds with `need_verify: true`; activate with `POST /api/verify`."
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/Credentials"
      responses:
        200:
          description: "Account created; verification email sent."
          content:
            application/json:
              schema:
                type: "object"
                properties:
                  need_verify:
                    type: "boolean"
                    const: true
                  message:
                    type: "string"
        400:
          description: "Bad request — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        401:
          description: "Unauthorized — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        404:
          description: "Not found — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        409:
          description: "Conflict — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        429:
          description: "Too many requests — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        500:
          description: "Server error — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  /api/login:
    post:
      tags:
        - "account"
      operationId: "loginAccount"
      summary: "Log in"
      description: "Verifies credentials and returns a JWT (30-day expiry) plus the user record. Unverified accounts receive a structured `not_verified` error."
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/Credentials"
      responses:
        200:
          description: "Authenticated."
          content:
            application/json:
              schema:
                type: "object"
                properties:
                  token:
                    type: "string"
                    description: "JWT; send as Authorization: Bearer <token>"
                  user:
                    "$ref": "#/components/schemas/User"
                  verified:
                    type: "boolean"
                    const: true
        400:
          description: "Bad request — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        401:
          description: "Unauthorized — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        404:
          description: "Not found — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        500:
          description: "Server error — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  /api/verify:
    post:
      tags:
        - "account"
      operationId: "verifyAccount"
      summary: "Activate an account with the emailed code"
      description: "Checks the 6-digit code, marks the account verified and returns a JWT."
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/VerifyRequest"
      responses:
        200:
          description: "Account verified; JWT returned."
          content:
            application/json:
              schema:
                type: "object"
                properties:
                  token:
                    type: "string"
                  user:
                    "$ref": "#/components/schemas/User"
                  verified:
                    type: "boolean"
                    const: true
        400:
          description: "Bad request — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        401:
          description: "Unauthorized — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        404:
          description: "Not found — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        500:
          description: "Server error — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  /api/resend:
    post:
      tags:
        - "account"
      operationId: "resendVerificationCode"
      summary: "Resend the verification email"
      description: "Generates and emails a fresh 6-digit code (valid 15 minutes)."
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/EmailOnly"
      responses:
        200:
          description: "Code sent."
          content:
            application/json:
              schema:
                type: "object"
                properties:
                  ok:
                    type: "boolean"
                    const: true
        400:
          description: "Bad request — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        401:
          description: "Unauthorized — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        404:
          description: "Not found — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        500:
          description: "Server error — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  /api/logout:
    post:
      tags:
        - "account"
      operationId: "logoutAccount"
      summary: "Log out (client-side token removal)"
      description: "Kept for interface completeness: tokens are removed client-side; this endpoint simply acknowledges."
      security: []
      responses:
        200:
          description: "Acknowledged."
          content:
            application/json:
              schema:
                type: "object"
                properties:
                  ok:
                    type: "boolean"
                    const: true
  /api/me:
    get:
      tags:
        - "account"
      operationId: "getCurrentUser"
      summary: "Current authenticated user"
      description: "Resolves the Bearer token to the user record."
      security:
        -
          BearerAuth: []
      responses:
        200:
          description: "The authenticated user."
          content:
            application/json:
              schema:
                type: "object"
                properties:
                  user:
                    "$ref": "#/components/schemas/User"
        400:
          description: "Bad request — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        401:
          description: "Unauthorized — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        404:
          description: "Not found — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        500:
          description: "Server error — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  /api/state:
    get:
      tags:
        - "account"
      operationId: "getState"
      summary: "Read the caller’s synced study state"
      description: "Returns the cloud-synced study state (answers, mastery, plan, spaced-repetition data) for the authenticated user."
      security:
        -
          BearerAuth: []
      responses:
        200:
          description: "Current state (may be null for a fresh account)."
          content:
            application/json:
              schema:
                type: "object"
                properties:
                  state:
                    "$ref": "#/components/schemas/StudyState"
        400:
          description: "Bad request — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        401:
          description: "Unauthorized — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        404:
          description: "Not found — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        500:
          description: "Server error — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    put:
      tags:
        - "account"
      operationId: "putState"
      summary: "Write the caller’s synced study state"
      description: "Overwrites the cloud copy of the study state. A timestamp is added server-side."
      security:
        -
          BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/PutStateRequest"
      responses:
        200:
          description: "State saved."
          content:
            application/json:
              schema:
                type: "object"
                properties:
                  ok:
                    type: "boolean"
                    const: true
        400:
          description: "Bad request — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        401:
          description: "Unauthorized — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        404:
          description: "Not found — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        500:
          description: "Server error — structured error envelope."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
components:
  securitySchemes:
    ApiKeyAuth:
      type: "apiKey"
      in: "header"
      name: "X-API-Key"
      description: "Self-serve scoped key. Free tier — create via POST /api/v1/keys."
      x-scopes:
        "meta:read": "Read access to meta resources"
        "questions:read": "Read access to questions resources"
        "stats:read": "Read access to stats resources"
    BearerAuth:
      type: "http"
      scheme: "bearer"
      bearerFormat: "JWT"
      description: "JWT issued by POST /api/login or POST /api/verify."
  schemas:
    Error:
      type: "object"
      description: "Structured error envelope returned by every non-2xx response."
      properties:
        error:
          type: "object"
          properties:
            code:
              type: "string"
              description: "Stable machine-readable error code, e.g. invalid_email."
            message:
              type: "string"
              description: "Human-readable summary."
            hint:
              type: "string"
              description: "Actionable resolution hint for agents."
            details:
              type: "object"
              description: "Optional machine-readable context (offending field, allowed values, etc.)."
          required:
            - "code"
            - "message"
            - "hint"
        status:
          type: "integer"
          description: "Mirrors the HTTP status code."
      required:
        - "error"
        - "status"
    Question:
      type: "object"
      properties:
        id:
          type: "string"
          examples:
            - "5002-001"
        subtest_code:
          type: "string"
          enum:
            - "5002"
            - "5003"
            - "5004"
            - "5005"
        subtest:
          type: "string"
        category:
          type: "string"
        question:
          type: "string"
        options:
          type: "array"
          items:
            type: "string"
        answer_index:
          type: "integer"
          minimum: 0
          maximum: 3
        answer_text:
          type: "string"
        explanation:
          type: "string"
      required:
        - "id"
        - "subtest_code"
        - "subtest"
        - "category"
        - "question"
        - "options"
        - "answer_index"
        - "answer_text"
        - "explanation"
    QuestionPage:
      type: "object"
      properties:
        ok:
          type: "boolean"
          const: true
        total:
          type: "integer"
          description: "Total matching questions before pagination."
        count:
          type: "integer"
          description: "Questions on this page."
        limit:
          type: "integer"
        offset:
          type: "integer"
        items:
          type: "array"
          items:
            "$ref": "#/components/schemas/Question"
    RandomQuestionSet:
      type: "object"
      properties:
        ok:
          type: "boolean"
          const: true
        count:
          type: "integer"
        total_pool:
          type: "integer"
        items:
          type: "array"
          items:
            "$ref": "#/components/schemas/Question"
    MetaResponse:
      type: "object"
      properties:
        product:
          type: "string"
          const: "Triumph"
        description:
          type: "string"
        base_url:
          type: "string"
          format: "uri"
        subtests:
          type: "object"
          description: "Map of subtest code → { name, questionCount, categories[] }"
        total_questions:
          type: "integer"
        passing_model:
          type: "string"
        docs:
          type: "object"
          properties:
            openapi:
              type: "string"
              format: "uri"
            developers:
              type: "string"
              format: "uri"
            mcp:
              type: "string"
              format: "uri"
            llms:
              type: "string"
              format: "uri"
    StatsResponse:
      type: "object"
      properties:
        ok:
          type: "boolean"
          const: true
        total:
          type: "integer"
        generatedAt:
          type: "string"
          format: "date"
        subtests:
          type: "object"
    CreateKeyRequest:
      type: "object"
      properties:
        email:
          type: "string"
          format: "email"
          description: "Optional — lets us contact you about breaking changes."
        scopes:
          type: "array"
          items:
            type: "string"
            enum:
              - "meta:read"
              - "questions:read"
              - "stats:read"
          default:
            - "meta:read"
            - "questions:read"
            - "stats:read"
          description: "Least-privilege scopes for this key."
    CreateKeyResponse:
      type: "object"
      properties:
        ok:
          type: "boolean"
          const: true
        key:
          type: "string"
          pattern: "^tri_live_[0-9a-f]{40}$"
          description: "Plaintext key — shown exactly once."
        scopes:
          type: "array"
          items:
            type: "string"
        tier:
          type: "string"
          const: "free"
        note:
          type: "string"
        docs:
          type: "string"
          format: "uri"
      required:
        - "ok"
        - "key"
        - "scopes"
        - "tier"
    Credentials:
      type: "object"
      properties:
        email:
          type: "string"
          format: "email"
        password:
          type: "string"
          minLength: 8
      required:
        - "email"
        - "password"
    EmailOnly:
      type: "object"
      properties:
        email:
          type: "string"
          format: "email"
      required:
        - "email"
    VerifyRequest:
      type: "object"
      properties:
        email:
          type: "string"
          format: "email"
        code:
          type: "string"
          minLength: 6
          maxLength: 6
          description: "6-digit code from the verification email."
      required:
        - "email"
        - "code"
    User:
      type: "object"
      properties:
        id:
          type: "string"
          format: "uuid"
        email:
          type: "string"
          format: "email"
      required:
        - "id"
        - "email"
    PutStateRequest:
      type: "object"
      properties:
        state:
          "$ref": "#/components/schemas/StudyState"
      required:
        - "state"
    StudyState:
      type: "object"
      description: "Free-form study-state document (answers, mastery map, plan, SRS data, exam date)."
      properties:
        answers:
          type: "array"
          items:
            type: "object"
        mastery:
          type: "object"
        plan:
          type:
            - "object"
            - "null"
        srs:
          type: "object"
        tasks:
          type: "object"
        examDate:
          type: "string"
        updatedAt:
          type: "integer"
          description: "Set by the server on write."
