openapi: 3.1.0

info:
  title: star-proctor API
  version: 0.0.0-dev
  summary: In-house, PDPL-resident proctoring service for exam.starcert.sa
  description: |
    Two surfaces:

    1. **Clean v1 API** under `/v1/*` — resource-oriented, snake_case payloads.
    2. **Proctor360 compatibility adapter** under `/v1/compat/proctor360/*` —
       translates literal Proctor360 endpoint shapes so `starcert/frontend` and
       `starcert/StarCert` can swap base URLs without refactoring.

    ## Conventions

    - All field names are `snake_case` (matches the DB and the Proctor360
      convention so the compat adapter doesn't have to map every field name).
    - Single-object responses are wrapped as `{ "data": <object> }`.
    - List responses wrap as `{ data, total, page, limit, lastPage }`.
    - Errors share the envelope `{ error, message?, details? }`.

    See the implementation plan in `docs/IMPLEMENTATION_PLAN.md` and the
    Architecture Decision Records in `docs/ADR/`.

  contact:
    name: starcert.sa engineering
    url: https://github.com/starcert/star-proctor
  license:
    name: Proprietary — Saudi Star Certification Body. Not for public use.
    identifier: LicenseRef-UNLICENSED

# Default: every operation requires a Bearer token. Public endpoints
# (OAuth token issuance, .seb file delivery for SEB itself, health) and
# candidate-facing endpoints (which authenticate via a launch_token, not a
# Bearer) opt out via `security: []` on the operation.
security:
  - bearerAuth: []

servers:
  - url: https://star-proctor-dev-api-eswfixqpja-wx.a.run.app
    description: Dev (live Cloud Run, me-central2) — current working environment
  - url: https://api.star-proctor.sa
    description: Production (KSA, me-central2 — custom domain, pending cut-over)
  - url: https://api.star-proctor.dev.starcert.sa
    description: Dev / staging (custom domain — not yet provisioned)
  - url: http://localhost:3000
    description: Local development

tags:
  - name: Health
    description: Liveness / readiness probes.
  - name: OAuth
    description: Client-credentials grant (RFC 6749 §4.4).
  - name: Exams
    description: Exam configuration sync from starcert/StarCert.
  - name: Sessions
    description: Proctored exam-attempt sessions.
  - name: Events
    description: Browser/system event ingestion (`tab_switch`, `fullscreen_exit`, etc.).
  - name: Incidents
    description: AI/CV-derived incidents (face absent, multi-face, voice, etc.).
  - name: Recordings
    description: Direct-to-storage media chunk uploads via signed URLs.
  - name: Enrollment
    description: Candidate ID photo + selfie capture and worker face-template callback.
  - name: SEB
    description: Safe Exam Browser Config Key validation and .seb file delivery.
  - name: Webhooks
    description: HMAC-signed webhook subscriptions.
  - name: Compat
    description: Literal Proctor360 endpoint shapes — for one-env-var cut-over.

paths:
  # =========================================================================
  # Health
  # =========================================================================
  /v1/health:
    get:
      tags: [Health]
      summary: Service health probe
      description: |
        Public readiness probe. Always returns HTTP 200 if the service is
        reachable; follow the `status` field (`ok` / `degraded`) rather than
        the HTTP status. Includes a DB ping (1 s timeout) and the active
        storage driver.
      operationId: getHealth
      security: []
      responses:
        '200':
          description: Service is reachable (see `status` for health)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'

  # =========================================================================
  # OAuth
  # =========================================================================
  /v1/oauth/token:
    post:
      tags: [OAuth]
      summary: client_credentials grant
      description: |
        Standard OAuth 2.0 client-credentials grant (RFC 6749 §4.4). Returns a
        bearer `access_token` for server-to-server callers (the exam-portal
        backends). Token TTL is 1 h; refresh by re-requesting. The body may be
        `application/x-www-form-urlencoded` or `application/json`.
      operationId: postOAuthToken
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/OAuthTokenRequest'
          application/json:
            schema:
              $ref: '#/components/schemas/OAuthTokenRequest'
      responses:
        '200':
          description: Token granted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthTokenResponse'
        '400':
          description: Malformed request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthError'
        '401':
          description: Bad credentials
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthError'

  # =========================================================================
  # Exams
  # =========================================================================
  /v1/exams:
    get:
      tags: [Exams]
      summary: List exams (paged)
      description: |
        Lists exam configurations, newest first. Lets the exam portal discover
        its exams rather than only fetch one by a known `external_exam_id`.
        `search` matches `external_exam_id` or `name`.
      operationId: listExams
      parameters:
        - in: query
          name: search
          required: false
          schema: { type: string }
        - in: query
          name: page
          required: false
          schema: { type: integer, minimum: 1, default: 1 }
        - in: query
          name: limit
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        '200':
          description: Paged list of exams
          content:
            application/json:
              schema:
                type: object
                required: [data, total, page, limit, lastPage]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Exam' }
                  total: { type: integer }
                  page: { type: integer }
                  limit: { type: integer }
                  lastPage: { type: integer }
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags: [Exams]
      summary: Create or upsert an exam configuration
      description: |
        Upserts on `external_exam_id`: if an exam with that id already exists
        it is updated, otherwise a new one is created. Returns the full exam row.
      operationId: createExam
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateExamRequest'
      responses:
        '201':
          description: Exam created or updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExamEnvelope'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /v1/exams/{external_exam_id}:
    parameters:
      - $ref: '#/components/parameters/ExternalExamId'
    get:
      tags: [Exams]
      summary: Get exam configuration
      description: Returns the exam config for the given `external_exam_id`.
      operationId: getExam
      responses:
        '200':
          description: Exam found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExamEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Exams]
      summary: Update exam configuration
      description: |
        Partial update — every field is optional. Omitted fields are left
        unchanged. `external_exam_id` cannot be changed.
      operationId: updateExam
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateExamRequest'
      responses:
        '200':
          description: Exam updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExamEnvelope'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  # =========================================================================
  # Sessions
  # =========================================================================
  /v1/sessions:
    post:
      tags: [Sessions]
      summary: Provision a proctored session for a candidate
      description: |
        Creates a session for a candidate against an existing exam config
        (referenced by `external_exam_id`). Mode flags (`ai_enabled`,
        `seb_required`, `secure_browsing`) are snapshotted from the exam at
        creation time. Fires the `session.created` webhook event. Returns the
        session row including the candidate `session_link`.
      operationId: createSession
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateSessionRequest'
      responses:
        '201':
          description: Session created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionEnvelope'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Referenced exam not found — create it first via `POST /v1/exams`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    get:
      tags: [Sessions]
      summary: List sessions
      description: Paginated list of sessions, newest first. Filter by exam and/or status.
      operationId: listSessions
      parameters:
        - in: query
          name: external_exam_id
          schema: { type: string }
          description: Filter to one exam.
        - in: query
          name: status
          schema:
            $ref: '#/components/schemas/SessionStatus'
          description: Filter to one lifecycle status.
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: A page of sessions
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionListResponse'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /v1/sessions/{session_id}:
    parameters:
      - $ref: '#/components/parameters/SessionId'
    get:
      tags: [Sessions]
      summary: Get one session
      description: |
        Returns a single session. The row carries biometric-derived fields
        (`face_similarity`, `did_face_match`) so every read is written to the
        PDPL audit log.
      operationId: getSession
      responses:
        '200':
          description: Session found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/sessions/{session_id}/close:
    parameters:
      - $ref: '#/components/parameters/SessionId'
    post:
      tags: [Sessions]
      summary: Close a session on exam submit
      description: |
        Marks the session `completed` and stamps `session_end_at` (if not
        already set). Fires the `session.completed` webhook event. Returns the
        updated session. Idempotent — closing an already-closed session is fine.

        No request body is needed, but this is a POST: send `Content-Length: 0`
        (or an empty body). `fetch`/`axios`/`requests` do this automatically; a
        raw `curl -X POST` with no body is rejected at the Cloud Run edge with
        a 411 before reaching the API, so use `curl ... -d ''`.
      operationId: closeSession
      responses:
        '200':
          description: Session closed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/sessions/{session_id}/live-token:
    parameters:
      - $ref: '#/components/parameters/SessionId'
    post:
      tags: [Sessions]
      summary: Mint a LiveKit token for the candidate to publish live media
      description: |
        Returns a short-lived LiveKit access token + the `wss://` URL the
        candidate's browser uses to publish webcam / screen / mic into the
        session's live room (room name = session id) for real-time proctor
        watch. Two callers: the lockdown SDK on the exam page authenticates with
        the session `launch_token` in the body (`{ "token": "<launch_token>" }`),
        cross-origin (CORS-enabled); the exam-portal backend may instead call it
        with a Bearer token. Returns 503 if live proctoring is not configured.
      operationId: candidateLiveToken
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                token:
                  type: string
                  description: Session launch_token (SDK / browser path). Omit when using Bearer auth.
      responses:
        '200':
          description: LiveKit publish token + connection URL.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      url: { type: string, description: 'LiveKit wss endpoint' }
                      token: { type: string, description: 'LiveKit JWT (publish grant)' }
                      room: { type: string }
                      identity: { type: string }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '503':
          description: Live proctoring not configured on this deployment.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # =========================================================================
  # Events
  # =========================================================================
  /v1/sessions/{session_id}/events:
    parameters:
      - $ref: '#/components/parameters/SessionId'
    post:
      tags: [Events]
      summary: Ingest one or many browser/system events
      description: |
        Accepts either a single event object OR a batch wrapped as
        `{ "events": [...] }` (1–500 events). Returns the count of inserted
        rows.
      operationId: ingestEvents
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/IngestEventsRequest'
      responses:
        '202':
          description: Accepted for processing
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IngestEventsResponse'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    get:
      tags: [Events]
      summary: List events
      description: |
        Lists events for the session. With `since`, returns events created
        after that timestamp in ascending order (cursor-style polling);
        without it, returns the most recent events in descending order.
      operationId: listEvents
      parameters:
        - in: query
          name: since
          schema: { type: string, format: date-time }
          description: Return only events created strictly after this timestamp (ascending).
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
          description: Max events to return.
      responses:
        '200':
          description: List of events
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventListResponse'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # =========================================================================
  # Incidents
  # =========================================================================
  /v1/sessions/{session_id}/incidents:
    parameters:
      - $ref: '#/components/parameters/SessionId'
    get:
      tags: [Incidents]
      summary: List AI/CV incidents
      description: Lists all incidents for the session, newest first.
      operationId: listIncidents
      responses:
        '200':
          description: List of incidents
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IncidentListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags: [Incidents]
      summary: Create an incident
      description: |
        Write path used by the client-side face detector (SDK) for cheap
        "no face" / "multiple faces" / "looking away" signals, and by the
        GKE worker for server-side CV/audio incidents. Same shape from both.
      operationId: createIncident
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateIncidentRequest'
      responses:
        '201':
          description: Incident created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IncidentEnvelope'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  # =========================================================================
  # Recordings
  # =========================================================================
  /v1/sessions/{session_id}/recordings/upload-url:
    parameters:
      - $ref: '#/components/parameters/SessionId'
    post:
      tags: [Recordings]
      summary: Request a signed upload URL for a media chunk
      description: |
        Mints a 15-minute V4 signed URL the browser can `PUT` a single media
        chunk directly to GCS. One URL per `(kind, chunk_index)` tuple; a
        `pending` recording row is created (or reused on retry). After a
        successful `PUT`, call `.../recordings/{recording_id}/complete`.

        Requires the GCS storage driver — returns `501` under the local
        storage driver.
      operationId: requestRecordingUploadUrl
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RequestUploadUrlRequest'
      responses:
        '200':
          description: Signed upload URL minted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UploadUrlResponse'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '501':
          description: Storage driver does not support signed uploads (local dev)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/sessions/{session_id}/recordings/{recording_id}/complete:
    parameters:
      - $ref: '#/components/parameters/SessionId'
      - $ref: '#/components/parameters/RecordingId'
    post:
      tags: [Recordings]
      summary: Mark a recording chunk upload complete
      description: |
        Called by the SDK after a successful `PUT` to the signed URL. Flips the
        recording row to `uploaded` and records final size / duration.
        Idempotent — re-calling on an already-uploaded chunk is a no-op success.
        The request body may be omitted (defaults to `{}`).
      operationId: completeRecordingUpload
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CompleteUploadRequest'
      responses:
        '200':
          description: Recording marked uploaded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RecordingEnvelope'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  # =========================================================================
  # Enrollment
  # =========================================================================
  /v1/sessions/{session_id}/face-template:
    parameters:
      - $ref: '#/components/parameters/SessionId'
    get:
      tags: [Enrollment]
      summary: Get the enrolled face template
      description: |
        Worker-facing. Returns the enrolled 512-dim ArcFace embedding so the
        worker can match each chunk frame against it without recomputing per
        chunk. `embedding` is `null` until enrollment completes. Requires the
        `incidents:write` scope.
      operationId: getFaceTemplate
      responses:
        '200':
          description: Face template (embedding may be null)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FaceTemplateEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags: [Enrollment]
      summary: Record the enrolled face template (worker callback)
      description: |
        Worker callback that writes the enrolled embedding plus the computed
        `face_similarity` and `did_face_match` boolean, flipping the session's
        `enrollment_status` to `enrolled`. Idempotent — overwrites any prior
        enrollment record. Requires the `incidents:write` scope.
      operationId: recordFaceTemplate
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostFaceTemplateRequest'
      responses:
        '200':
          description: Face template recorded; returns the updated session
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionEnvelope'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/netcheck:
    get:
      tags: [Enrollment]
      summary: Bandwidth/latency probe for the check-in wizard
      description: |
        Returns `bytes` bytes of filler (capped at 2 MB) with `no-store`
        headers so the candidate check-in wizard can time a real download
        against this origin to estimate connection speed. No auth; leaks
        nothing.
      operationId: netcheck
      security: []
      parameters:
        - in: query
          name: bytes
          required: false
          schema: { type: integer, minimum: 0, maximum: 2000000 }
          description: Number of filler bytes to return.
      responses:
        '200':
          description: Filler bytes
          content:
            application/octet-stream:
              schema: { type: string, format: binary }

  /v1/sessions/{session_id}/launch:
    parameters:
      - $ref: '#/components/parameters/SessionId'
    get:
      tags: [Enrollment]
      summary: Candidate launch page (camera check + ID/selfie capture)
      description: |
        The candidate-facing HTML page that the `session.session_link` field
        points at. Hand the candidate the `session_link` returned by session
        create/get (or open it in their browser). Authenticates via the
        session's `launch_token` passed as `?token=`; an invalid or missing
        token renders a 404 page. Returns `text/html` — it is a rendered page,
        not a JSON data endpoint.
      operationId: candidateLaunchPage
      security: []
      parameters:
        - in: query
          name: token
          required: true
          schema: { type: string }
          description: The session launch_token (returned by POST /v1/sessions).
      responses:
        '200':
          description: The candidate launch page.
          content:
            text/html:
              schema: { type: string }
        '404':
          description: Invalid session or launch token (rendered as an HTML page).
          content:
            text/html:
              schema: { type: string }

  /v1/sessions/{session_id}/live-publish:
    parameters:
      - $ref: '#/components/parameters/SessionId'
    get:
      tags: [Enrollment]
      summary: Candidate live-publish widget (iframe-embeddable)
      description: |
        Candidate-facing HTML page that publishes the candidate's webcam, mic,
        and (on click) screen into the session's LiveKit room for real-time
        proctor watch. The exam page embeds it as an iframe during the exam:
        `<iframe src="/v1/sessions/{id}/live-publish?token={launch_token}"
        allow="camera; microphone; display-capture"></iframe>`. Authenticates
        via the session `launch_token` (no Bearer in the browser). Renders an
        "live proctoring not enabled" message if LiveKit isn't configured.
      operationId: candidateLivePublish
      security: []
      parameters:
        - in: query
          name: token
          required: true
          schema: { type: string }
          description: The session launch_token (returned by POST /v1/sessions).
      responses:
        '200':
          description: The candidate live-publish page.
          content:
            text/html:
              schema: { type: string }
        '404':
          description: Invalid session or launch token (rendered as an HTML page).
          content:
            text/html:
              schema: { type: string }

  /v1/sessions/{session_id}/id-photo:
    parameters:
      - $ref: '#/components/parameters/SessionId'
    post:
      tags: [Enrollment]
      summary: Upload the candidate's government-ID photo
      description: |
        Candidate-facing multipart upload from the launch page. The candidate
        does **not** hold a Bearer token — this endpoint authenticates via the
        session's `launch_token`, supplied as the `token` form field OR a
        `?token=` query parameter. Accepts JPEG / PNG / WebP up to 8 MiB.
      operationId: uploadIdPhoto
      security: []
      parameters:
        - in: query
          name: token
          required: false
          schema: { type: string }
          description: The session launch_token (alternative to the `token` form field).
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/EnrollmentPhotoUpload'
      responses:
        '201':
          description: Photo uploaded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnrollmentUploadEnvelope'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          description: Invalid session or launch token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          description: Photo exceeds the 8 MiB limit
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '415':
          description: Unsupported image type (must be JPEG / PNG / WebP)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/sessions/{session_id}/selfie:
    parameters:
      - $ref: '#/components/parameters/SessionId'
    post:
      tags: [Enrollment]
      summary: Upload the candidate's selfie
      description: |
        Candidate-facing multipart upload from the launch page. Same shape and
        auth as `id-photo` — authenticates via the session's `launch_token`
        (form field or `?token=` query parameter). Uploading the selfie (once
        an ID photo is also present) is what triggers worker enrollment.
      operationId: uploadSelfie
      security: []
      parameters:
        - in: query
          name: token
          required: false
          schema: { type: string }
          description: The session launch_token (alternative to the `token` form field).
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/EnrollmentPhotoUpload'
      responses:
        '201':
          description: Selfie uploaded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnrollmentUploadEnvelope'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          description: Invalid session or launch token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          description: Photo exceeds the 8 MiB limit
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '415':
          description: Unsupported image type (must be JPEG / PNG / WebP)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/sessions/{session_id}/validate-enrollment:
    parameters:
      - $ref: '#/components/parameters/SessionId'
    post:
      tags: [Enrollment]
      summary: Validate the candidate's ID + selfie (synchronous identity check)
      description: |
        Candidate-facing. Called by the check-in wizard once both the ID photo
        and selfie are uploaded. Authenticates via the session `launch_token`
        (JSON `token` field or `?token=` query). The API mints short-lived
        signed URLs for both images and asks the always-on worker to verify:

          * a face is present on the ID portrait and on the selfie,
          * the selfie matches the ID portrait (ArcFace cosine similarity),
          * a real government ID/card was shown (self-hosted OCR finds text).

        Identity problems do **not** block the candidate — they are recorded on
        the session and raised as a proctor-review incident (`id_not_presented`
        / `face_not_matched`), while the wizard surfaces a soft warning. The
        selfie embedding is persisted as the enrolled face template for live
        monitoring.

        Returns `503` if synchronous validation isn't enabled on this
        deployment (no worker wired) and `502` on a transient worker failure —
        the wizard treats both as "skip" and lets the candidate continue.
      operationId: validateEnrollment
      security: []
      parameters:
        - in: query
          name: token
          required: false
          schema: { type: string }
          description: The session launch_token (alternative to the JSON `token` field).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                token:
                  type: string
                  description: The session launch_token.
      responses:
        '200':
          description: Validation completed (see body for the verdict)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id_face_present: { type: boolean }
                      selfie_face_present: { type: boolean }
                      id_document_present:
                        type: [boolean, 'null']
                        description: True if a real ID/card with readable text was detected; null when the OCR check could not run (e.g. the OCR service was briefly unavailable) — the candidate is not flagged.
                      did_id_match:
                        type: [boolean, 'null']
                        description: True if the selfie matched the ID portrait.
                      face_similarity:
                        type: [number, 'null']
                        description: Cosine similarity [-1,1] between ID and selfie faces.
                      id_number:
                        type: [string, 'null']
                        description: Best-effort OCR-extracted ID number.
        '401':
          description: Invalid session or launch token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: ID photo and/or selfie not yet uploaded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '502':
          description: Worker validation failed transiently (client should skip)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: Synchronous validation not enabled on this deployment
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/sessions/{session_id}/consent:
    parameters:
      - $ref: '#/components/parameters/SessionId'
    post:
      tags: [Enrollment]
      summary: Record the candidate's biometric/recording consent
      description: |
        Candidate-facing (launch_token auth). The check-in wizard posts this the
        moment the candidate accepts consent, so PDPL gets the required record:
        an append-only audit-log entry with the consent scopes + timestamp + IP
        + user-agent. You don't call this directly — the wizard does.
      operationId: recordConsent
      security: []
      parameters:
        - in: query
          name: token
          required: false
          schema: { type: string }
          description: The session launch_token (alternative to the JSON `token` field).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                token: { type: string }
                scopes:
                  type: array
                  items: { type: string }
                  description: Consented scopes (default camera, microphone, screen, id_verification).
      responses:
        '200':
          description: Consent recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ok: { type: boolean }
                      scopes: { type: array, items: { type: string } }
        '401':
          description: Invalid session or launch token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/sessions/{session_id}/proctor-event:
    parameters:
      - $ref: '#/components/parameters/SessionId'
    post:
      tags: [Enrollment]
      summary: Report a proctored-shell event (exam start / lockdown violation)
      description: |
        Candidate-facing (launch_token auth). Posted by the hosted proctored
        exam shell:

          * `{ "type": "exam_started" }` → marks the session `in_progress`.
          * `{ "type": "tab_switch" | "window_switch" | "fullscreen_exit"
               | "focus_lost" | "multiple_monitors" | "key_command"
               | "right_click" | "screen_share_stopped" | "help_requested",
               "severity": "warning" }` → writes a proctor-review incident.

        Browser-only (Mode A) lockdown DETECTS these; it does not hard-block
        them (that needs SEB / Mode B). They are recorded for the proctor.
      operationId: proctorEvent
      security: []
      parameters:
        - in: query
          name: token
          required: false
          schema: { type: string }
          description: The session launch_token (alternative to the JSON `token` field).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties:
                token: { type: string }
                type: { type: string }
                severity: { type: string, enum: [info, warning, critical] }
      responses:
        '200':
          description: Event recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ok: { type: boolean }
                      status: { type: string }
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          description: Invalid session or launch token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  # =========================================================================
  # SEB (Safe Exam Browser)
  # =========================================================================
  /v1/seb/sessions/{session_id}/verify:
    parameters:
      - $ref: '#/components/parameters/SessionId'
    post:
      tags: [SEB]
      summary: Validate the SEB Config Key hash (server-to-server)
      description: |
        Server-to-server SEB Config Key validation. The exam-portal backend
        forwards the absolute URL the browser is on, the
        `X-SafeExamBrowser-ConfigKeyHash` header value it received, and
        (optionally) a previously-issued `seb-verified` cookie value.

        On header-path success, returns an HMAC-signed `cookie` the caller
        should set so subsequent same-browser requests re-authorize without
        fresh SEB headers.
      operationId: verifySeb
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SebVerifyRequest'
      responses:
        '200':
          description: Verification result (check the `valid` field)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SebVerifyResponse'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /v1/seb/configs:
    post:
      tags: [SEB]
      summary: Upload a .seb config file
      description: |
        Multipart upload of a `.seb` file (≤ 2 MiB). Returns the new
        `seb_configs` row so the caller can store its id on an exam config
        (`Exam.seb_config_file_id`). Intended for admin tooling.
      operationId: createSebConfig
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/SebConfigUpload'
      responses:
        '201':
          description: .seb config stored
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SebConfigEnvelope'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '413':
          description: File exceeds the 2 MiB limit
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/seb/configs/{file_id}:
    parameters:
      - in: path
        name: file_id
        required: true
        schema: { type: string, format: uuid }
        description: The seb_configs row id.
    get:
      tags: [SEB]
      summary: Download a .seb config file
      description: |
        Public — no Bearer token. Safe Exam Browser fetches this URL with the
        user's OS-level HTTP client (not the browser), so the request arrives
        without cookies or a session. Anti-abuse: only returns bytes if some
        non-deleted exam still references the file id; old / unreferenced
        configs 404. Served as `application/seb` with a `Content-Disposition`
        attachment so the OS routes it to SEB.
      operationId: getSebConfig
      security: []
      responses:
        '200':
          description: The .seb file bytes
          content:
            application/seb:
              schema: { type: string, format: binary }
        '404':
          $ref: '#/components/responses/NotFound'

  # =========================================================================
  # Webhooks
  # =========================================================================
  /v1/webhooks:
    post:
      tags: [Webhooks]
      summary: Register an HMAC-signed webhook subscription
      description: |
        Returns the plaintext `signing_secret` EXACTLY ONCE. Store it on the
        receiver side; star-proctor only keeps a hash. Receivers verify each
        delivery by recomputing `sha256=<HMAC-SHA256(secret, body)>` and
        comparing constant-time against the `X-Star-Proctor-Signature-256`
        request header.
      operationId: createWebhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateWebhookRequest'
      responses:
        '201':
          description: Webhook registered (signing_secret returned once)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreatedWebhookEnvelope'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
    get:
      tags: [Webhooks]
      summary: List the calling client's webhook subscriptions
      description: Lists the calling client's own webhooks. `secret_hash` is never returned.
      operationId: listWebhooks
      responses:
        '200':
          description: List of webhooks
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /v1/webhooks/{webhook_id}:
    parameters:
      - in: path
        name: webhook_id
        required: true
        schema: { type: string, format: uuid }
        description: The webhook subscription id.
    delete:
      tags: [Webhooks]
      summary: Delete a webhook subscription
      description: |
        Hard-deletes the subscription (no soft delete on this table). Scoped to
        the calling client's own webhooks only.
      operationId: deleteWebhook
      responses:
        '204':
          description: Deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  # =========================================================================
  # Proctor360 compatibility adapter
  # =========================================================================
  /v1/compat/proctor360/organisations/{org_id}/oauth/token:
    parameters:
      - in: path
        name: org_id
        required: true
        schema: { type: string }
        description: Proctor360 organisation id; pinned against the client row.
    post:
      tags: [Compat]
      summary: Proctor360-shaped OAuth token endpoint
      description: |
        Proctor360-compatibility OAuth. Accepts `multipart/form-data`,
        `application/x-www-form-urlencoded`, or `application/json` with fields
        `app_id` + `app_secret`. The response matches Proctor360 exactly —
        just `{ access_token }`, no `token_type` / `expires_in`.
      operationId: compatProctor360OAuthToken
      security: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/CompatOAuthTokenRequest'
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/CompatOAuthTokenRequest'
          application/json:
            schema:
              $ref: '#/components/schemas/CompatOAuthTokenRequest'
      responses:
        '200':
          description: Token granted (Proctor360 shape)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompatOAuthTokenResponse'
        '400':
          description: Malformed request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthError'
        '401':
          description: Bad credentials
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthError'

  /v1/compat/proctor360/exams:
    post:
      tags: [Compat]
      summary: Create / upsert an exam (Proctor360 shape)
      description: |
        Proctor360-shaped exam create. Accepts form-urlencoded / multipart /
        JSON. Boolean fields arrive as `"0"` / `"1"` strings;
        `secure_browsing_id` is a JSON-array or CSV string of numeric IDs.
        Returns the exam in Proctor360 shape wrapped in `{ data }`.
      operationId: compatProctor360CreateExam
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/CompatExamRequest'
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/CompatExamRequest'
          application/json:
            schema:
              $ref: '#/components/schemas/CompatExamRequest'
      responses:
        '200':
          description: Exam created / updated (Proctor360 shape)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompatExamEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          description: Missing required fields (`exam_name`, `client_exam_id`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/compat/proctor360/exam-updates/{client_exam_id}:
    parameters:
      - $ref: '#/components/parameters/ClientExamId'
    patch:
      tags: [Compat]
      summary: Update an exam (Proctor360 shape)
      description: |
        Proctor360 quirk: updates use `PATCH /exam-updates/{client_exam_id}`,
        not `PATCH /exams/{client_exam_id}`. Same field shape as the create
        endpoint. Returns the exam in Proctor360 shape.
      operationId: compatProctor360UpdateExam
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/CompatExamRequest'
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/CompatExamRequest'
          application/json:
            schema:
              $ref: '#/components/schemas/CompatExamRequest'
      responses:
        '200':
          description: Exam updated (Proctor360 shape)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompatExamEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/compat/proctor360/exams/{client_exam_id}:
    parameters:
      - $ref: '#/components/parameters/ClientExamId'
    get:
      tags: [Compat]
      summary: Get a Proctor360-shaped exam config
      description: Returns the exam config in Proctor360 shape, wrapped in `{ data }`.
      operationId: compatProctor360GetExam
      responses:
        '200':
          description: Exam found (Proctor360 shape)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompatExamEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/compat/proctor360/student-sessions:
    post:
      tags: [Compat]
      summary: Create a student session (Proctor360 shape)
      description: |
        Proctor360-shaped session create. Accepts form-urlencoded / multipart /
        JSON with `first_name`, `last_name`, `student_email`, `client_exam_id`,
        and optional `exam_date_time` (`YYYY-MM-DD HH:mm:ss`) / `timezone`.
        Returns the session in Proctor360 shape **directly** (NOT wrapped in
        `{ data }`), matching Proctor360 verbatim.
      operationId: compatProctor360CreateStudentSession
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/CompatStudentSessionRequest'
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/CompatStudentSessionRequest'
          application/json:
            schema:
              $ref: '#/components/schemas/CompatStudentSessionRequest'
      responses:
        '200':
          description: Session created (Proctor360 shape, unwrapped)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompatSession'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          description: Missing required fields
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    get:
      tags: [Compat]
      summary: List student sessions (Proctor360 shape)
      description: |
        Proctor360-paginated list. Accepts `client_exam_id`, `status` (P360
        wording), `page`, and `limit` / `perPage`. Returns
        `{ total, perPage, page, lastPage, data: [...] }`.
      operationId: compatProctor360ListStudentSessions
      parameters:
        - in: query
          name: client_exam_id
          schema: { type: string }
        - in: query
          name: status
          schema: { type: string }
          description: 'Proctor360 status wording (e.g. "in progress", "not started").'
        - in: query
          name: page
          schema: { type: integer, minimum: 1, default: 1 }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, default: 15 }
        - in: query
          name: perPage
          schema: { type: integer, minimum: 1 }
          description: Proctor360 alias for `limit`.
      responses:
        '200':
          description: A page of sessions (Proctor360 shape)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompatSessionListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /v1/compat/proctor360/student-session/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: string }
        description: The session id (Proctor360 uses singular `student-session` for get-one).
    get:
      tags: [Compat]
      summary: Get one student session (Proctor360 shape)
      description: Returns a single session in Proctor360 shape, wrapped in `{ data }`.
      operationId: compatProctor360GetStudentSession
      responses:
        '200':
          description: Session found (Proctor360 shape)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompatSessionEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/compat/proctor360/sessions-check:
    get:
      tags: [Compat]
      summary: Check for an existing session (Proctor360 shape)
      description: |
        Proctor360 quirk: returns HTTP 422 with
        `{ errors: { title: "Session already exists" }, session_id }` when a
        session already exists for `(student_email, client_exam_id)`. Otherwise
        returns `{ data: { schedule_link, my_session_link } }`.
      operationId: compatProctor360SessionsCheck
      parameters:
        - in: query
          name: student_email
          schema: { type: string }
        - in: query
          name: client_exam_id
          schema: { type: string }
      responses:
        '200':
          description: No existing session
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompatSessionsCheckResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          description: Session already exists
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompatSessionExistsResponse'

  /v1/compat/proctor360/session-close:
    post:
      tags: [Compat]
      summary: Close a session (Proctor360 shape)
      description: |
        Proctor360 shape: `POST /session-close` with body `{ session_id }`
        (form-urlencoded / multipart / JSON). Returns
        `{ success: true, data: <session in Proctor360 shape> }`.
      operationId: compatProctor360CloseSession
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/CompatSessionCloseRequest'
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/CompatSessionCloseRequest'
          application/json:
            schema:
              $ref: '#/components/schemas/CompatSessionCloseRequest'
      responses:
        '200':
          description: Session closed (Proctor360 shape)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompatSessionCloseResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Missing `session_id`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/compat/proctor360/browser-activities:
    post:
      tags: [Compat]
      summary: Ingest a browser activity (Proctor360 shape)
      description: |
        Proctor360 shape: `POST /browser-activities` with body
        `{ session_link, activity_name, event, name?, url? }`. The session is
        resolved by `session_link` (the public URL minted on session create).
        Returns `{ success: true, data: { inserted } }`.
      operationId: compatProctor360BrowserActivities
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/CompatBrowserActivityRequest'
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/CompatBrowserActivityRequest'
          application/json:
            schema:
              $ref: '#/components/schemas/CompatBrowserActivityRequest'
      responses:
        '200':
          description: Activity ingested (Proctor360 shape)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompatBrowserActivityResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No session matches `session_link`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: Missing required fields
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/compat/proctor360/ai-incidence/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: string }
        description: The student-session id.
    get:
      tags: [Compat]
      summary: AI incidents for a student session (Proctor360 shape)
      description: |
        Returns incidents projected into Proctor360's `AIIncident` shape,
        wrapped in `{ data }`. Empty until the worker lands (Phase 5+).
      operationId: compatProctor360AiIncidence
      responses:
        '200':
          description: AI incidents (Proctor360 shape)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompatAiIncidenceResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /v1/compat/proctor360/browser-incidence/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: string }
        description: The student-session id.
    get:
      tags: [Compat]
      summary: Browser activity logs for a student session (Proctor360 shape)
      description: |
        Projects the session's events into Proctor360's `BrowserIncident`
        shape (including `display_activity` icon hints), wrapped in `{ data }`.
      operationId: compatProctor360BrowserIncidence
      responses:
        '200':
          description: Browser incidents (Proctor360 shape)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CompatBrowserIncidenceResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/compat/proctor360/storage/{path}:
    parameters:
      - in: path
        name: path
        required: true
        schema: { type: string }
        description: The blob storage path (catch-all; may contain slashes).
    get:
      tags: [Compat]
      summary: Fetch an evidence image (Proctor360 /storage shape)
      description: |
        Authenticated proxy that serves evidence images / captures from blob
        storage under the Proctor360 `/storage/{path}` scheme. Returns the
        binary image with its stored content type.
      operationId: compatProctor360Storage
      responses:
        '200':
          description: Binary image
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '400':
          description: Invalid storage path
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        OAuth 2.0 access token issued by `POST /v1/oauth/token` (or the
        Proctor360-compat `POST /v1/compat/proctor360/organisations/{org_id}/oauth/token`).

  parameters:
    ExternalExamId:
      in: path
      name: external_exam_id
      required: true
      schema: { type: string }
      description: The exam id assigned by the consuming system (Directus Exam id).
    ClientExamId:
      in: path
      name: client_exam_id
      required: true
      schema: { type: string }
      description: Proctor360's name for `external_exam_id`.
    SessionId:
      in: path
      name: session_id
      required: true
      schema: { type: string, format: uuid }
      description: The session id.
    RecordingId:
      in: path
      name: recording_id
      required: true
      schema: { type: string, format: uuid }
      description: The recording (media chunk) id returned by the upload-url endpoint.
    Page:
      in: query
      name: page
      schema: { type: integer, minimum: 1, default: 1 }
      description: 1-based page number.
    Limit:
      in: query
      name: limit
      schema: { type: integer, minimum: 1, maximum: 100, default: 15 }
      description: Page size.

  responses:
    Unauthorized:
      description: Missing or invalid Bearer token
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: Token lacks the required scope
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    ValidationError:
      description: Request validation failed
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

  schemas:
    # ---- shared envelopes / errors ----
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Machine-readable error code.
          example: not_found
        message:
          type: string
          description: Human-readable explanation.
          example: Session 0190f6c0-... not found
        details:
          description: Optional structured details (e.g. Zod validation flatten output).
      example:
        error: invalid_request
        message: Validation failed
        details:
          fieldErrors:
            student.email: ['Invalid email']

    HealthResponse:
      type: object
      required: [status, service, time]
      properties:
        status: { type: string, enum: [ok, degraded] }
        service: { type: string, example: star-proctor-api }
        version: { type: string, example: 0.0.0-dev }
        region: { type: string, example: me-central2 }
        time: { type: string, format: date-time }
        checks:
          type: object
          properties:
            db:
              type: object
              properties:
                status: { type: string, enum: [ok, down] }
                latency_ms: { type: [integer, 'null'] }
            storage:
              type: object
              properties:
                driver: { type: string, example: gcs }
        latency_ms: { type: integer }
      example:
        status: ok
        service: star-proctor-api
        version: 0.0.0-dev
        region: me-central2
        time: '2026-06-14T10:00:00.000Z'
        checks:
          db: { status: ok, latency_ms: 3 }
          storage: { driver: gcs }
        latency_ms: 5

    # ---- OAuth ----
    OAuthTokenRequest:
      type: object
      required: [grant_type, client_id, client_secret]
      properties:
        grant_type:
          type: string
          const: client_credentials
        client_id: { type: string }
        client_secret: { type: string }
        scope:
          type: string
          description: Optional space-delimited scope request.
      example:
        grant_type: client_credentials
        client_id: starcert-frontend
        client_secret: s3cr3t

    OAuthTokenResponse:
      type: object
      required: [access_token, token_type, expires_in]
      properties:
        access_token: { type: string }
        token_type: { type: string, const: Bearer }
        expires_in: { type: integer, example: 3600 }
        scope: { type: string }
      example:
        access_token: eyJhbGciOiJIUzI1NiIsImtpZCI6...
        token_type: Bearer
        expires_in: 3600
        scope: incidents:write

    OAuthError:
      type: object
      required: [error]
      properties:
        error:
          type: string
          enum: [invalid_request, invalid_client, invalid_grant]
        error_description: { type: string }
      example:
        error: invalid_client
        error_description: client_secret is required

    # ---- Exam ----
    SecureBrowsingOption:
      type: string
      enum:
        - right_click
        - key_command
        - fullscreen
        - link_click
        - multiple_monitors
        - multi_tabs
        - tab_switch
        - window_switch
        - close_tabs
        - whole_monitor

    CreateExamRequest:
      type: object
      required: [external_exam_id, name]
      properties:
        external_exam_id: { type: string, minLength: 1 }
        name: { type: string, minLength: 1 }
        description: { type: [string, 'null'] }
        duration_minutes: { type: [integer, 'null'], minimum: 1 }
        live_proctoring: { type: boolean }
        mobile_camera: { type: boolean }
        fullview_camera: { type: boolean }
        camspin: { type: boolean }
        face_verification: { type: boolean }
        ai_enabled: { type: boolean }
        secure_browsing: { type: boolean }
        can_upload_attachment: { type: boolean }
        secure_browsing_options:
          type: array
          items:
            $ref: '#/components/schemas/SecureBrowsingOption'
        seb_config_key: { type: [string, 'null'] }
        seb_config_file_id: { type: [string, 'null'], format: uuid }
        student_notes: { type: [string, 'null'] }
        proctor_notes: { type: [string, 'null'] }
        exam_launch_url:
          type: [string, 'null']
          format: uri
          description: |
            Absolute URL the proctored shell embeds after check-in passes.
            Default for every session of this exam; a per-session `redirect_url`
            overrides it. Surfaced on the dashboard exam-details page.
        iframe_url:
          type: [string, 'null']
          format: uri
          description: |
            Alias for `exam_launch_url` (the field examcert sends at exam
            creation). Maps to `exam_launch_url`; wins if both are supplied. The
            response echoes it back equal to `exam_launch_url`.
        proctor_language:
          type: string
          enum: [en, ar, fr]
          default: en
          description: |
            Default candidate proctoring language (check-in wizard + lockdown
            SDK). Snapshotted onto each session; the candidate can switch during
            check-in. Arabic renders right-to-left.
      example:
        external_exam_id: directus-exam-42
        name: ISO 9001 Lead Auditor
        duration_minutes: 120
        ai_enabled: true
        secure_browsing: true
        secure_browsing_options: [tab_switch, fullscreen]

    UpdateExamRequest:
      type: object
      description: Partial update — all fields optional; `external_exam_id` cannot be changed.
      properties:
        name: { type: string, minLength: 1 }
        description: { type: [string, 'null'] }
        duration_minutes: { type: [integer, 'null'], minimum: 1 }
        live_proctoring: { type: boolean }
        mobile_camera: { type: boolean }
        fullview_camera: { type: boolean }
        camspin: { type: boolean }
        face_verification: { type: boolean }
        ai_enabled: { type: boolean }
        secure_browsing: { type: boolean }
        can_upload_attachment: { type: boolean }
        secure_browsing_options:
          type: array
          items:
            $ref: '#/components/schemas/SecureBrowsingOption'
        seb_config_key: { type: [string, 'null'] }
        seb_config_file_id: { type: [string, 'null'], format: uuid }
        student_notes: { type: [string, 'null'] }
        proctor_notes: { type: [string, 'null'] }
        exam_launch_url: { type: [string, 'null'], format: uri }
        iframe_url: { type: [string, 'null'], format: uri, description: 'Alias for exam_launch_url (examcert sends this).' }
        proctor_language: { type: string, enum: [en, ar, fr], default: en, description: 'Default candidate proctoring language.' }
      example:
        ai_enabled: false
        duration_minutes: 90

    Exam:
      type: object
      required:
        - id
        - external_exam_id
        - name
        - live_proctoring
        - mobile_camera
        - fullview_camera
        - camspin
        - face_verification
        - ai_enabled
        - secure_browsing
        - can_upload_attachment
        - secure_browsing_options
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        external_exam_id: { type: string }
        name: { type: string }
        description: { type: [string, 'null'] }
        duration_minutes: { type: [integer, 'null'] }
        live_proctoring: { type: boolean }
        mobile_camera: { type: boolean }
        fullview_camera: { type: boolean }
        camspin: { type: boolean }
        face_verification: { type: boolean }
        ai_enabled: { type: boolean }
        secure_browsing: { type: boolean }
        can_upload_attachment: { type: boolean }
        secure_browsing_options:
          type: array
          items:
            $ref: '#/components/schemas/SecureBrowsingOption'
        seb_config_key: { type: [string, 'null'] }
        seb_config_file_id: { type: [string, 'null'], format: uuid }
        student_notes: { type: [string, 'null'] }
        proctor_notes: { type: [string, 'null'] }
        exam_launch_url: { type: [string, 'null'], format: uri, description: 'Default post-check-in exam URL the proctored shell embeds for sessions of this exam.' }
        iframe_url: { type: [string, 'null'], format: uri, description: 'Echo of exam_launch_url under examcert''s alias name.' }
        created_by: { type: [string, 'null'], description: 'OAuth client_id that created this exam (set on first create).' }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        deleted_at: { type: [string, 'null'], format: date-time }
      example:
        id: 0190f6b0-1111-7aaa-8000-000000000001
        external_exam_id: directus-exam-42
        name: ISO 9001 Lead Auditor
        description: null
        duration_minutes: 120
        live_proctoring: false
        mobile_camera: false
        fullview_camera: false
        camspin: false
        face_verification: true
        ai_enabled: true
        secure_browsing: true
        can_upload_attachment: false
        secure_browsing_options: [tab_switch, fullscreen]
        seb_config_key: null
        seb_config_file_id: null
        student_notes: null
        proctor_notes: null
        created_at: '2026-06-14T09:00:00.000Z'
        updated_at: '2026-06-14T09:00:00.000Z'
        deleted_at: null

    ExamEnvelope:
      type: object
      required: [data]
      properties:
        data:
          $ref: '#/components/schemas/Exam'

    # ---- Session ----
    SessionStatus:
      type: string
      enum: [pending, checks_completed, in_progress, completed, cancelled, expired]

    EnrollmentStatus:
      type: string
      enum: [pending, id_uploaded, selfie_uploaded, enrolled, failed]

    CreateSessionRequest:
      type: object
      required: [external_exam_id, student]
      properties:
        external_exam_id: { type: string, minLength: 1 }
        student:
          type: object
          required: [first_name, last_name, email]
          properties:
            external_id: { type: [string, 'null'] }
            first_name: { type: string, minLength: 1 }
            last_name: { type: string, minLength: 1 }
            email: { type: string, format: email }
        exam_date_time:
          type: [string, 'null']
          format: date-time
          description: ISO 8601 date-time string.
        timezone: { type: [string, 'null'] }
        redirect_url:
          type: [string, 'null']
          format: uri
          description: |
            Per-attempt exam launch link. Overrides the exam's `exam_launch_url`
            as the URL the proctored shell embeds after check-in passes.
        iframe_url:
          type: [string, 'null']
          format: uri
          description: Alias for `redirect_url` (examcert's field name).
      example:
        external_exam_id: directus-exam-42
        student:
          external_id: stu-7
          first_name: Layla
          last_name: Al-Otaibi
          email: layla@example.com
        exam_date_time: '2026-06-20T13:00:00.000Z'
        timezone: Asia/Riyadh

    Session:
      type: object
      required:
        - id
        - exam_id
        - external_exam_id
        - student_first_name
        - student_last_name
        - student_email
        - status
        - ai_enabled
        - seb_required
        - secure_browsing
        - launch_token
        - enrollment_status
        - session_link
        - created_at
        - updated_at
      properties:
        id: { type: string, format: uuid }
        display_code:
          type: [string, 'null']
          description: |
            Human-friendly per-session reference (e.g. "ABC-234") to show people
            (exam page under the candidate name, dashboard, support). NOT an
            identifier — `id` (uuid) remains the key for all URLs, the LiveKit
            room, and storage. Unique; generated at session create.
          example: ABC-234
        exam_id: { type: string, format: uuid }
        external_exam_id: { type: string }
        student_external_id: { type: [string, 'null'] }
        student_first_name: { type: string }
        student_last_name: { type: string }
        student_email: { type: string, format: email }
        exam_date_time: { type: [string, 'null'], format: date-time }
        timezone: { type: [string, 'null'] }
        status:
          $ref: '#/components/schemas/SessionStatus'
        session_start_at: { type: [string, 'null'], format: date-time }
        session_end_at: { type: [string, 'null'], format: date-time }
        ai_enabled: { type: boolean }
        seb_required: { type: boolean }
        secure_browsing: { type: boolean }
        proctor_language: { type: string, enum: [en, ar, fr], description: 'Candidate proctoring language (snapshot of the exam default); pass to the lockdown SDK as `language`.' }
        ai_score: { type: [integer, 'null'] }
        did_face_match: { type: [boolean, 'null'] }
        did_id_match: { type: [boolean, 'null'] }
        face_similarity: { type: [number, 'null'] }
        launch_token:
          type: string
          description: 32-byte random hex; authorizes candidate-facing uploads.
        id_photo_blob_ref: { type: [string, 'null'] }
        selfie_blob_ref: { type: [string, 'null'] }
        enrolled_face_template:
          type: [array, 'null']
          items: { type: number }
          description: 512-dim L2-normalised ArcFace embedding.
        enrollment_status:
          $ref: '#/components/schemas/EnrollmentStatus'
        enrollment_failure_reason: { type: [string, 'null'] }
        id_document_present:
          type: [boolean, 'null']
          description: True if a real ID/card was detected at check-in; false = bare selfie / no card (flagged).
        id_ocr:
          type: [object, 'null']
          description: 'OCR extraction from the ID image: { char_count, text, number, available }. available=false means the OCR service could not be reached at check-in.'
        session_link: { type: string, format: uri }
        redirect_url:
          type: [string, 'null']
          format: uri
          description: Per-session override of the exam's post-check-in launch URL.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
      example:
        id: 0190f6c0-2222-7bbb-8000-000000000002
        exam_id: 0190f6b0-1111-7aaa-8000-000000000001
        external_exam_id: directus-exam-42
        student_external_id: stu-7
        student_first_name: Layla
        student_last_name: Al-Otaibi
        student_email: layla@example.com
        exam_date_time: '2026-06-20T13:00:00.000Z'
        timezone: Asia/Riyadh
        status: pending
        session_start_at: null
        session_end_at: null
        ai_enabled: true
        seb_required: false
        secure_browsing: true
        ai_score: null
        did_face_match: null
        did_id_match: null
        face_similarity: null
        launch_token: 7f3a...e21c
        id_photo_blob_ref: null
        selfie_blob_ref: null
        enrolled_face_template: null
        enrollment_status: pending
        enrollment_failure_reason: null
        session_link: https://api.star-proctor.sa/v1/sessions/0190f6c0-2222-7bbb-8000-000000000002/launch?token=7f3a...e21c
        created_at: '2026-06-14T09:30:00.000Z'
        updated_at: '2026-06-14T09:30:00.000Z'

    SessionEnvelope:
      type: object
      required: [data]
      properties:
        data:
          $ref: '#/components/schemas/Session'

    SessionListResponse:
      type: object
      required: [data, total, page, limit, lastPage]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Session'
        total: { type: integer, example: 42 }
        page: { type: integer, example: 1 }
        limit: { type: integer, example: 15 }
        lastPage: { type: integer, example: 3 }

    # ---- Event ----
    Event:
      type: object
      required: [id, session_id, activity_name, event, created_at]
      properties:
        id: { type: string, format: uuid }
        session_id: { type: string, format: uuid }
        activity_name: { type: string }
        event: { type: string }
        source_name: { type: [string, 'null'] }
        source_url: { type: [string, 'null'] }
        meta:
          description: Arbitrary JSON payload captured with the event.
        created_at: { type: string, format: date-time }
      example:
        id: 0190f6d0-3333-7ccc-8000-000000000003
        session_id: 0190f6c0-2222-7bbb-8000-000000000002
        activity_name: Tab switch
        event: visibilitychange
        source_name: null
        source_url: null
        meta: null
        created_at: '2026-06-14T09:45:00.000Z'

    EventInput:
      type: object
      required: [activity_name, event]
      properties:
        activity_name: { type: string, minLength: 1 }
        event: { type: string, minLength: 1 }
        source_name: { type: [string, 'null'] }
        source_url: { type: [string, 'null'] }
        meta:
          description: Arbitrary JSON payload to store with the event.
      example:
        activity_name: Tab switch
        event: visibilitychange

    IngestEventsRequest:
      description: 'Either a single event OR a batch wrapped as `{ events: [...] }`.'
      oneOf:
        - $ref: '#/components/schemas/EventInput'
        - type: object
          required: [events]
          properties:
            events:
              type: array
              minItems: 1
              maxItems: 500
              items:
                $ref: '#/components/schemas/EventInput'

    IngestEventsResponse:
      type: object
      required: [inserted]
      properties:
        inserted: { type: integer, example: 1 }

    EventListResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Event'

    # ---- Incident ----
    IncidentSeverity:
      type: string
      enum: [info, warning, critical]

    IncidentLabel:
      type: object
      required: [name, confidence]
      properties:
        name: { type: string, minLength: 1 }
        confidence: { type: number, minimum: 0, maximum: 1 }
      example:
        name: multiple_faces
        confidence: 0.93

    CreateIncidentRequest:
      type: object
      required: [severity, labels]
      properties:
        severity:
          $ref: '#/components/schemas/IncidentSeverity'
        labels:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/IncidentLabel'
        face_matched: { type: [boolean, 'null'] }
        evidence_image_ref: { type: [string, 'null'] }
        occurred_at:
          type: [string, 'null']
          format: date-time
          description: When it happened in the exam (worker sends the chunk capture time). Defaults to now().
      example:
        severity: warning
        labels:
          - name: multiple_faces
            confidence: 0.93
        face_matched: false
        evidence_image_ref: media/0190f6c0/frame/000123.jpg

    Incident:
      type: object
      required: [id, session_id, severity, labels, created_at]
      properties:
        id: { type: string, format: uuid }
        session_id: { type: string, format: uuid }
        severity:
          $ref: '#/components/schemas/IncidentSeverity'
        labels:
          type: array
          items:
            $ref: '#/components/schemas/IncidentLabel'
        face_matched: { type: [boolean, 'null'] }
        evidence_image_ref: { type: [string, 'null'] }
        occurred_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }
      example:
        id: 0190f6e0-4444-7ddd-8000-000000000004
        session_id: 0190f6c0-2222-7bbb-8000-000000000002
        severity: warning
        labels:
          - name: multiple_faces
            confidence: 0.93
        face_matched: false
        evidence_image_ref: media/0190f6c0/frame/000123.jpg
        created_at: '2026-06-14T09:50:00.000Z'

    IncidentEnvelope:
      type: object
      required: [data]
      properties:
        data:
          $ref: '#/components/schemas/Incident'

    IncidentListResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Incident'

    # ---- Recording ----
    RecordingKind:
      type: string
      enum: [webcam, audio, screen, mobile]

    RecordingStatus:
      type: string
      enum: [pending, uploaded, failed]

    RequestUploadUrlRequest:
      type: object
      required: [kind, chunk_index]
      properties:
        kind:
          $ref: '#/components/schemas/RecordingKind'
        chunk_index: { type: integer, minimum: 0 }
        content_type: { type: string, default: video/webm }
      example:
        kind: webcam
        chunk_index: 0
        content_type: video/webm

    UploadUrlResponse:
      type: object
      required: [recording_id, upload_url, blob_ref, expires_at, method, required_headers]
      properties:
        recording_id: { type: string, format: uuid }
        upload_url: { type: string, format: uri }
        blob_ref: { type: string }
        expires_at: { type: string, format: date-time }
        method: { type: string, const: PUT }
        required_headers:
          type: object
          required: [Content-Type]
          properties:
            Content-Type: { type: string }
      example:
        recording_id: 0190f6f0-5555-7eee-8000-000000000005
        upload_url: https://storage.googleapis.com/star-proctor-media/media/0190f6c0/webcam/000000.webm?X-Goog-Signature=...
        blob_ref: media/0190f6c0/webcam/000000.webm
        expires_at: '2026-06-14T10:05:00.000Z'
        method: PUT
        required_headers:
          Content-Type: video/webm

    CompleteUploadRequest:
      type: object
      properties:
        size_bytes: { type: [integer, 'null'], minimum: 0 }
        duration_seconds: { type: [number, 'null'], minimum: 0 }
      example:
        size_bytes: 524288
        duration_seconds: 10.0

    Recording:
      type: object
      required: [id, session_id, kind, chunk_index, blob_ref, content_type, status, created_at]
      properties:
        id: { type: string, format: uuid }
        session_id: { type: string, format: uuid }
        kind:
          $ref: '#/components/schemas/RecordingKind'
        chunk_index: { type: integer }
        blob_ref: { type: string }
        content_type: { type: string }
        size_bytes: { type: [integer, 'null'] }
        duration_seconds: { type: [number, 'null'] }
        status:
          $ref: '#/components/schemas/RecordingStatus'
        uploaded_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }
      example:
        id: 0190f6f0-5555-7eee-8000-000000000005
        session_id: 0190f6c0-2222-7bbb-8000-000000000002
        kind: webcam
        chunk_index: 0
        blob_ref: media/0190f6c0/webcam/000000.webm
        content_type: video/webm
        size_bytes: 524288
        duration_seconds: 10.0
        status: uploaded
        uploaded_at: '2026-06-14T10:01:00.000Z'
        created_at: '2026-06-14T10:00:30.000Z'

    RecordingEnvelope:
      type: object
      required: [data]
      properties:
        data:
          $ref: '#/components/schemas/Recording'

    # ---- Enrollment ----
    EnrollmentPhotoUpload:
      type: object
      required: [file]
      properties:
        file:
          type: string
          format: binary
          description: Image bytes (JPEG / PNG / WebP, ≤ 8 MiB).
        token:
          type: string
          description: The session launch_token (alternative to the `?token=` query parameter).

    EnrollmentUpload:
      type: object
      required: [blob_ref, size_bytes, content_type]
      properties:
        blob_ref: { type: string }
        size_bytes: { type: integer }
        content_type: { type: string }
      example:
        blob_ref: enrollment/0190f6c0/id.jpg
        size_bytes: 204800
        content_type: image/jpeg

    EnrollmentUploadEnvelope:
      type: object
      required: [data]
      properties:
        data:
          $ref: '#/components/schemas/EnrollmentUpload'

    PostFaceTemplateRequest:
      type: object
      required: [embedding, face_similarity, did_face_match]
      properties:
        embedding:
          type: array
          minItems: 512
          maxItems: 512
          items: { type: number }
          description: L2-normalised ArcFace embedding (exactly 512 floats).
        face_similarity:
          type: number
          minimum: -1
          maximum: 1
          description: Cosine similarity between the ID-photo face and the selfie face.
          example: 0.81
        did_face_match:
          type: boolean
          description: Worker's boolean decision from face_similarity vs threshold.
          example: true

    FaceTemplate:
      type: object
      required: [embedding, enrollment_status]
      properties:
        embedding:
          type: [array, 'null']
          items: { type: number }
          description: The enrolled 512-dim embedding, or null if not yet enrolled.
        enrollment_status:
          $ref: '#/components/schemas/EnrollmentStatus'
      example:
        embedding: null
        enrollment_status: selfie_uploaded

    FaceTemplateEnvelope:
      type: object
      required: [data]
      properties:
        data:
          $ref: '#/components/schemas/FaceTemplate'

    # ---- SEB ----
    SebVerifyRequest:
      type: object
      required: [url]
      properties:
        url:
          type: string
          minLength: 1
          description: The absolute URL SEB used when computing its ConfigKey hash.
        header_hash:
          type: [string, 'null']
          description: The X-SafeExamBrowser-ConfigKeyHash header value.
        cookie:
          type: [string, 'null']
          description: A previously-issued seb-verified cookie value, if present.
      example:
        url: https://exam.starcert.sa/exam/abc?x=1
        header_hash: 9f8e7d...
        cookie: null

    SebVerifyResponse:
      type: object
      required: [valid]
      properties:
        valid: { type: boolean }
        via:
          type: string
          enum: [header, cookie]
          description: Which path validated (present only when valid).
        reason:
          type: string
          enum: [missing_header, hash_mismatch, exam_config_missing, session_not_found]
          description: Why validation failed (present only when invalid).
        cookie:
          type: string
          description: HMAC-signed cookie to set (present only on header-path success).
      example:
        valid: true
        via: header
        cookie: '0190f6c0|1750000000|abc123...'

    SebConfigUpload:
      type: object
      required: [file]
      properties:
        file:
          type: string
          format: binary
          description: The .seb file bytes (≤ 2 MiB).
        filename:
          type: string
          description: Optional override of the multipart filename.

    SebConfig:
      type: object
      required: [id, filename, content_type, blob_ref, uploaded_by, created_at]
      properties:
        id: { type: string, format: uuid }
        filename: { type: string }
        content_type: { type: string, example: application/seb }
        blob_ref: { type: string }
        uploaded_by: { type: string }
        created_at: { type: string, format: date-time }
      example:
        id: 0190f700-6666-7fff-8000-000000000006
        filename: exam.seb
        content_type: application/seb
        blob_ref: seb-configs/0190f700-6666-7fff-8000-000000000006.seb
        uploaded_by: starcert-admin
        created_at: '2026-06-14T08:00:00.000Z'

    SebConfigEnvelope:
      type: object
      required: [data]
      properties:
        data:
          $ref: '#/components/schemas/SebConfig'

    # ---- Webhooks ----
    WebhookEventType:
      type: string
      enum: [session.created, session.completed]

    CreateWebhookRequest:
      type: object
      required: [url, events]
      properties:
        url: { type: string, format: uri }
        events:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/WebhookEventType'
      example:
        url: https://starcert.sa/api/proctor-webhook
        events: [session.created, session.completed]

    Webhook:
      type: object
      required: [id, client_id, url, events, active, created_at]
      properties:
        id: { type: string, format: uuid }
        client_id: { type: string, format: uuid }
        url: { type: string, format: uri }
        events:
          type: array
          items:
            $ref: '#/components/schemas/WebhookEventType'
        active: { type: boolean }
        created_at: { type: string, format: date-time }
      example:
        id: 0190f710-7777-7aaa-8000-000000000007
        client_id: 0190f6a0-0000-7000-8000-000000000000
        url: https://starcert.sa/api/proctor-webhook
        events: [session.created, session.completed]
        active: true
        created_at: '2026-06-14T08:30:00.000Z'

    CreatedWebhook:
      type: object
      required: [webhook, signing_secret]
      properties:
        webhook:
          $ref: '#/components/schemas/Webhook'
        signing_secret:
          type: string
          description: Plaintext signing secret — returned ONCE. Store it on the receiver.
      example:
        webhook:
          id: 0190f710-7777-7aaa-8000-000000000007
          client_id: 0190f6a0-0000-7000-8000-000000000000
          url: https://starcert.sa/api/proctor-webhook
          events: [session.created, session.completed]
          active: true
          created_at: '2026-06-14T08:30:00.000Z'
        signing_secret: 9c2f...base64url

    CreatedWebhookEnvelope:
      type: object
      required: [data]
      properties:
        data:
          $ref: '#/components/schemas/CreatedWebhook'

    WebhookListResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Webhook'

    # ---- Compat: OAuth ----
    CompatOAuthTokenRequest:
      type: object
      required: [app_id, app_secret]
      properties:
        app_id: { type: string, minLength: 1 }
        app_secret: { type: string, minLength: 1 }
      example:
        app_id: starcert-app
        app_secret: s3cr3t

    CompatOAuthTokenResponse:
      type: object
      required: [access_token]
      properties:
        access_token: { type: string }
      example:
        access_token: eyJhbGciOiJIUzI1NiIsImtpZCI6...

    # ---- Compat: Exam ----
    CompatExamRequest:
      type: object
      description: |
        Proctor360-shaped exam fields. Boolean fields arrive as `"0"`/`"1"`
        strings; `secure_browsing_id` is a JSON-array or CSV string of numeric
        secure-browsing IDs (1–9).
      required: [exam_name, client_exam_id]
      properties:
        exam_name: { type: string }
        client_exam_id: { type: string }
        exam_description: { type: string }
        duration: { type: string, description: Duration in minutes (numeric string). }
        live_proctoring: { type: string, enum: ['0', '1'] }
        mobile_camera: { type: string, enum: ['0', '1'] }
        fullview_camera: { type: string, enum: ['0', '1'] }
        camspin: { type: string, enum: ['0', '1'] }
        face_verification: { type: string, enum: ['0', '1'] }
        ai_enabled: { type: string, enum: ['0', '1'] }
        secure_browsing: { type: string, enum: ['0', '1'] }
        can_upload_attachment: { type: string, enum: ['0', '1'] }
        secure_browsing_id:
          type: string
          description: 'JSON-array or CSV of numeric secure-browsing IDs, e.g. "[7,3]" or "7,3".'
        student_notes: { type: string }
        proctor_notes: { type: string }
      example:
        exam_name: ISO 9001 Lead Auditor
        client_exam_id: directus-exam-42
        duration: '120'
        ai_enabled: '1'
        secure_browsing: '1'
        secure_browsing_id: '[7,3]'

    CompatExam:
      type: object
      description: Proctor360-shaped exam. The subset of ~80 P360 fields the consumers read.
      properties:
        id: { type: [string, 'null'] }
        client_exam_id: { type: string }
        exam_name: { type: string }
        exam_description: { type: [string, 'null'] }
        student_notes: { type: [string, 'null'] }
        proctor_notes: { type: [string, 'null'] }
        duration: { type: integer }
        status: { type: string, enum: [active, archived] }
        live_proctoring: { type: integer, enum: [0, 1] }
        mobile_camera: { type: integer, enum: [0, 1] }
        fullview_camera: { type: integer, enum: [0, 1] }
        camspin: { type: integer, enum: [0, 1] }
        face_verification: { type: integer, enum: [0, 1] }
        secure_browsing: { type: integer, enum: [0, 1] }
        ai_enabled: { type: integer, enum: [0, 1] }
        can_upload_attachment: { type: integer, enum: [0, 1] }
        web_camera: { type: integer, enum: [0, 1] }
        screenshare: { type: integer, enum: [0, 1] }
        enable_iframe: { type: integer, enum: [0, 1] }
        iframe_url: { type: [string, 'null'] }
        iframe_new_tab: { type: integer, enum: [0, 1] }
        secureBrowsings:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              name: { type: string }
              slug: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CompatExamEnvelope:
      type: object
      required: [data]
      properties:
        data:
          $ref: '#/components/schemas/CompatExam'

    # ---- Compat: Session ----
    CompatStudentSessionRequest:
      type: object
      required: [first_name, last_name, student_email, client_exam_id]
      properties:
        first_name: { type: string }
        last_name: { type: string }
        student_email: { type: string, format: email }
        client_exam_id: { type: string }
        exam_date_time:
          type: string
          description: 'Proctor360 datetime format "YYYY-MM-DD HH:mm:ss".'
        timezone: { type: string }
      example:
        first_name: Layla
        last_name: Al-Otaibi
        student_email: layla@example.com
        client_exam_id: directus-exam-42
        exam_date_time: '2026-06-20 13:00:00'
        timezone: Asia/Riyadh

    CompatSession:
      type: object
      description: Proctor360-shaped session (subset of fields the consumers read).
      properties:
        id: { type: string }
        client_exam_id: { type: string }
        student_id: { type: [string, 'null'] }
        exam_date_time: { type: [string, 'null'], format: date-time }
        session_start_at: { type: [string, 'null'], format: date-time }
        session_end_at: { type: [string, 'null'], format: date-time }
        status: { type: string, description: 'Proctor360 wording (e.g. "not started").' }
        session_link: { type: string }
        display_session_link: { type: string }
        secure_browsing: { type: integer, enum: [0, 1] }
        ai_enabled: { type: integer, enum: [0, 1] }
        didFaceMatch: { type: integer, enum: [0, 1] }
        didIdMatch: { type: integer, enum: [0, 1] }
        face_similarity: { type: number }
        ai_score: { type: [integer, 'null'] }
        student:
          type: object
          properties:
            id: { type: [string, 'null'] }
            first_name: { type: string }
            last_name: { type: string }
            email: { type: string }
            fullname: { type: string }
            timezone:
              type: [object, 'null']
              properties:
                zone_id: { type: [string, 'null'] }
                zone_name: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
      example:
        id: 0190f6c0-2222-7bbb-8000-000000000002
        client_exam_id: directus-exam-42
        student_id: stu-7
        status: not started
        session_link: https://api.star-proctor.sa/v1/sessions/0190f6c0-2222-7bbb-8000-000000000002/launch?token=7f3a
        secure_browsing: 1
        ai_enabled: 1
        didFaceMatch: 0
        didIdMatch: 0
        face_similarity: 0
        student:
          id: stu-7
          first_name: Layla
          last_name: Al-Otaibi
          email: layla@example.com
          fullname: Layla Al-Otaibi
          timezone: { zone_id: null, zone_name: Asia/Riyadh }
        created_at: '2026-06-14T09:30:00.000Z'
        updated_at: '2026-06-14T09:30:00.000Z'

    CompatSessionEnvelope:
      type: object
      required: [data]
      properties:
        data:
          $ref: '#/components/schemas/CompatSession'

    CompatSessionListResponse:
      type: object
      required: [total, perPage, page, lastPage, data]
      properties:
        total: { type: integer }
        perPage: { type: integer }
        page: { type: integer }
        lastPage: { type: integer }
        data:
          type: array
          items:
            $ref: '#/components/schemas/CompatSession'

    CompatSessionsCheckResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          properties:
            schedule_link: { type: [string, 'null'] }
            my_session_link: { type: [string, 'null'] }
      example:
        data:
          schedule_link: null
          my_session_link: null

    CompatSessionExistsResponse:
      type: object
      required: [errors]
      properties:
        errors:
          type: object
          properties:
            title: { type: string }
        session_id: { type: string }
      example:
        errors:
          title: Session already exists
        session_id: 0190f6c0-2222-7bbb-8000-000000000002

    CompatSessionCloseRequest:
      type: object
      required: [session_id]
      properties:
        session_id: { type: string }
      example:
        session_id: 0190f6c0-2222-7bbb-8000-000000000002

    CompatSessionCloseResponse:
      type: object
      required: [success, data]
      properties:
        success: { type: boolean, const: true }
        data:
          $ref: '#/components/schemas/CompatSession'

    # ---- Compat: Browser activities ----
    CompatBrowserActivityRequest:
      type: object
      required: [session_link, activity_name, event]
      properties:
        session_link: { type: string }
        activity_name: { type: string }
        event: { type: string }
        name: { type: string, description: Optional source name. }
        url: { type: string, description: Optional source URL. }
      example:
        session_link: https://api.star-proctor.sa/v1/sessions/0190f6c0/launch?token=7f3a
        activity_name: Tab switch
        event: visibilitychange

    CompatBrowserActivityResponse:
      type: object
      required: [success, data]
      properties:
        success: { type: boolean, const: true }
        data:
          $ref: '#/components/schemas/IngestEventsResponse'

    # ---- Compat: Incidence ----
    CompatAiIncident:
      type: object
      properties:
        _id: { type: string }
        id: { type: string }
        labels:
          type: array
          items:
            $ref: '#/components/schemas/IncidentLabel'
        face_matched: { type: boolean }
        image: { type: string }
        organisation_id: { type: [string, 'null'] }
        session_link: { type: [string, 'null'] }
        student_session_id: { type: string }
        exam_id: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CompatAiIncidenceResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/CompatAiIncident'

    CompatBrowserIncident:
      type: object
      properties:
        _id: { type: string }
        id: { type: string }
        student:
          type: object
          properties:
            id: { type: [string, 'null'] }
            first_name: { type: string }
            last_name: { type: string }
            email: { type: string }
        source:
          type: object
          properties:
            name: { type: [string, 'null'] }
            url: { type: [string, 'null'] }
        session_link: { type: string }
        student_session_id: { type: string }
        exam_id: { type: [string, 'null'] }
        organisation_id: { type: [string, 'null'] }
        activity_name: { type: string }
        event: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        display_activity:
          type: object
          properties:
            time: { type: string, format: date-time }
            url: { type: string }
            activity: { type: string }
            icon: { type: string }
            type: { type: string }
            event: { type: string }

    CompatBrowserIncidenceResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/CompatBrowserIncident'
