openapi: 3.0.3
info:
  title: IES CyberGuardian — Safe-Link API
  version: "1.0.0"
  description: |
    Pre-click link assessment. A URL goes in, a scored verdict comes out, signed
    with RSASSA-PSS over SHA-256 (PS256) so the caller can prove the verdict came
    from CyberGuardian without trusting the transport.

    Only the address and minimal metadata are accepted. The service never fetches
    the page behind a link, and it must never be sent page content, cookies or
    credentials.

    This automated assessment is informational only. Not forensic evidence.
servers:
  - url: https://iesciberguardianapp.abacusai.app
    description: Production
tags:
  - name: check
  - name: feedback
  - name: verification
  - name: operations
paths:
  /api/v1/check/link:
    post:
      tags: [check]
      summary: Score a link before it is opened
      description: |
        Open to anonymous callers on purpose: a person about to click a link
        should not have to create an account first. Anonymous callers are limited
        to 20 checks per hour per connection, signed-in callers to 120.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  description: The address to assess. Defanged forms (hxxp, [.]) are accepted.
                  example: "https://paypa1-secure-login.top/verify?id=99"
                context:
                  type: string
                  enum: [paste, bookmarklet, screenshot, api]
                  default: paste
                client_id:
                  type: string
                  maxLength: 120
                  description: Free-form partner identifier, echoed nowhere and used only for reporting.
                lang:
                  type: string
                  maxLength: 12
      responses:
        "200":
          description: A verdict
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Verdict"
        "400":
          $ref: "#/components/responses/BadRequest"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"
  /api/v1/outcome:
    post:
      tags: [feedback]
      summary: Record what the person did after seeing a verdict
      description: |
        The only input to pre-click prevention conversion. Recorded once per
        verdict and never overwritten, so the figure cannot be improved after
        the fact.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [request_id, outcome]
              properties:
                request_id:
                  type: string
                outcome:
                  type: string
                  enum: [avoided, opened, copied]
      responses:
        "200":
          description: Outcome state for that verdict
          content:
            application/json:
              schema:
                type: object
                properties:
                  request_id: { type: string }
                  outcome: { type: string }
                  recorded:
                    type: boolean
                    description: False when an outcome was already recorded.
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/appeal:
    post:
      tags: [feedback]
      summary: Dispute a verdict
      description: A human reviewer decides within 24 hours. The SLA clock starts at creation.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [request_id, note]
              properties:
                request_id: { type: string }
                note:
                  type: string
                  minLength: 4
                  maxLength: 2000
      responses:
        "201":
          description: Appeal created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Appeal"
        "200":
          description: An open appeal already exists for that verdict
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Appeal"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
  /api/v1/report:
    post:
      tags: [feedback]
      summary: Send new evidence about a link
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [request_id, evidence]
              properties:
                request_id: { type: string }
                evidence:
                  type: string
                  minLength: 4
                  maxLength: 4000
      responses:
        "201":
          description: Report received
          content:
            application/json:
              schema:
                type: object
                properties:
                  report_id: { type: string }
                  status: { type: string, enum: [received, triaged, actioned, dismissed] }
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
  /api/v1/whitelist/{url_hash}:
    get:
      tags: [check]
      summary: Whether one exact address carries an exception
      description: Takes a hash, not a URL, so the question can be asked without sending us the link.
      parameters:
        - name: url_hash
          in: path
          required: true
          schema:
            type: string
            pattern: "^[a-f0-9]{64}$"
          description: sha256 hex digest of the canonical URL.
      responses:
        "200":
          description: Exception state
          content:
            application/json:
              schema:
                type: object
                properties:
                  url_hash: { type: string }
                  whitelisted: { type: boolean }
                  reason: { type: string, nullable: true }
                  expires_at: { type: string, format: date-time, nullable: true }
        "400":
          $ref: "#/components/responses/BadRequest"
  /.well-known/cyberguardian-keys:
    get:
      tags: [verification]
      summary: Public signing keys as a JWK set
      responses:
        "200":
          description: JWK set
          content:
            application/jwk-set+json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items:
                      type: object
                      properties:
                        kty: { type: string, example: RSA }
                        use: { type: string, example: sig }
                        alg: { type: string, example: PS256 }
                        kid: { type: string }
                        n: { type: string }
                        e: { type: string, example: AQAB }
  /api/v1/verify:
    post:
      tags: [verification]
      summary: Verify a verdict token
      description: |
        A convenience for proving an integration end to end on day one. Partners
        should verify locally against the JWK set instead: that is the point of
        signing the verdict.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token: { type: string }
      responses:
        "200":
          description: Verification result
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid: { type: boolean }
                  reason: { type: string, nullable: true }
                  header: { type: object, nullable: true }
                  payload:
                    $ref: "#/components/schemas/VerdictTokenPayload"
        "400":
          $ref: "#/components/responses/BadRequest"
  /api/v1/extract-links:
    post:
      tags: [check]
      summary: Read the addresses out of a screenshot
      description: |
        The image is used in memory only, to recover text, and is never stored. No
        verdict is produced here: the caller confirms which address to check,
        because one misread character is a different website.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [image]
              properties:
                image:
                  type: string
                  description: Image data URI, at most 8 MB decoded.
                  example: "data:image/png;base64,iVBORw0KGgo..."
      responses:
        "200":
          description: Candidate addresses
          content:
            application/json:
              schema:
                type: object
                properties:
                  links:
                    type: array
                    items: { type: string }
                  transcript: { type: string }
        "400":
          $ref: "#/components/responses/BadRequest"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Text extraction is not configured on this deployment.
        "504":
          description: Reading the screenshot took too long.
  /api/v1/appeals/{appeal_id}/decision:
    post:
      tags: [operations]
      summary: Record a reviewer decision on an appeal
      security:
        - sessionCookie: []
      parameters:
        - name: appeal_id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision, rationale]
              properties:
                decision:
                  type: string
                  enum: [whitelisted, rejected, escalated]
                rationale:
                  type: string
                  minLength: 4
                  maxLength: 2000
      responses:
        "200":
          description: Decision recorded, with an audit-trail entry naming the reviewer
          content:
            application/json:
              schema:
                type: object
                properties:
                  appeal_id: { type: string }
                  status: { type: string }
                  decided_at: { type: string, format: date-time, nullable: true }
                  within_sla: { type: boolean }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: The caller is not a reviewer.
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The appeal was already decided.
  /api/v1/ops/metrics:
    get:
      tags: [operations]
      summary: Operational metrics for the last N days
      security:
        - sessionCookie: []
      parameters:
        - name: days
          in: query
          required: false
          schema: { type: integer, default: 30, minimum: 1, maximum: 365 }
      responses:
        "200":
          description: |
            Verdict distribution, override rate, latency percentiles, appeals and
            SLA state, and pre-click prevention conversion. Figures with no data
            behind them are null, never zero.
          content:
            application/json:
              schema: { type: object }
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: The caller is not an operator.
components:
  securitySchemes:
    sessionCookie:
      type: apiKey
      in: cookie
      name: next-auth.session-token
      description: Operator endpoints use the application session. Public endpoints need no credential.
  responses:
    BadRequest:
      description: The request was malformed or the URL could not be parsed.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: invalid_url, message: "That does not look like a web address." }
    Unauthenticated:
      description: No session.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No record for that identifier.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Too many calls from this caller. Honour the Retry-After header.
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Error"
              - type: object
                properties:
                  retry_after_seconds: { type: integer }
    ServerError:
      description: The check could not be completed.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    Error:
      type: object
      properties:
        error: { type: string }
        message: { type: string }
    Appeal:
      type: object
      properties:
        appeal_id: { type: string }
        status: { type: string, enum: [open, whitelisted, rejected, escalated] }
        sla_due_at: { type: string, format: date-time }
        sla_hours: { type: integer, example: 24 }
        duplicate: { type: boolean }
    Verdict:
      type: object
      properties:
        request_id: { type: string, example: "0f0a6a1e-2f4a-4d1e-9d0a-6c2f5b8a1c33" }
        score: { type: integer, minimum: 0, maximum: 100, example: 82 }
        level: { type: string, enum: [low, medium, high], example: high }
        top_flag: { type: string, nullable: true, example: lookalike_domain }
        short_action: { type: string, example: "Do not open" }
        action_line:
          type: string
          example: "High risk — Do not open. Verify with official site."
        explain_short:
          type: string
          description: At most ten words.
          example: "Lookalike domain imitating a known brand"
        explain_long:
          type: string
          description: Two lines, the second one a concrete next step.
        explain_url: { type: string, format: uri }
        ttl_seconds: { type: integer, example: 3600 }
        whitelisted: { type: boolean }
        cached: { type: boolean }
        url_hash: { type: string }
        canonical_url: { type: string }
        host: { type: string }
        flags:
          type: array
          items:
            type: object
            properties:
              code: { type: string }
              detail: { type: string }
        model_version: { type: string, example: "safe-link-1.0+gpt-5.4-mini" }
        latency_ms: { type: integer }
        token_signed:
          type: string
          nullable: true
          description: JWT-shaped verdict token signed with PS256. Null if no signing key is configured.
        disclaimer:
          type: string
          description: Fixed sentence that must be shown wherever the verdict is displayed.
          example: "This automated assessment is informational only. Not forensic evidence." 
      example:
        request_id: "0f0a6a1e-2f4a-4d1e-9d0a-6c2f5b8a1c33"
        score: 82
        level: high
        top_flag: lookalike_domain
        short_action: "Do not open"
        action_line: "High risk — Do not open. Verify with official site."
        explain_short: "Lookalike domain imitating a known brand"
        explain_long: "The address imitates paypal.com but is registered elsewhere.\nDo not open it. Call the company on the number printed on your card or statement, never a number in the message."
        ttl_seconds: 3600
        whitelisted: false
        cached: false
        host: "paypa1-secure-login.top"
    VerdictTokenPayload:
      type: object
      description: The signed payload. Field names are stable and safe to depend on.
      properties:
        request_id: { type: string }
        url_hash: { type: string }
        score: { type: integer }
        level: { type: string, enum: [low, medium, high] }
        top_flag: { type: string, nullable: true }
        model_version: { type: string }
        issued_at: { type: integer, description: Unix time in seconds. }
        expires_at: { type: integer, description: Unix time in seconds. }
        issuer: { type: string, example: cyberguardian }
        aud: { type: string, nullable: true, description: Present only when a verdict was issued for a named partner. }
