openapi: 3.0.3
info:
  title: RequestScope URL Risk API
  version: 1.8.0
  description: |
    Evidence-backed URL tracing and phishing-risk assessment for Copilot and automation.

    Request bodies are strict: fields outside the published schemas are rejected with 400 naming the field, matching the additionalProperties declarations here. Known endpoints answer unsupported methods with 405 and an Allow header. Cross-origin browser clients can read ETag, Location, Retry-After, and Content-Disposition because they are listed in Access-Control-Expose-Headers.
servers:
  - url: https://requestscope.illek.ie
security:
  - {}
  - bearerAuth: []
x-ai-usage:
  preferred-interface: REST or Streamable HTTP MCP
  mcp-endpoint: https://requestscope.illek.ie/mcp/v2
  mcp-protocol-version: 2025-11-25
  mcp-tools: [trace_request, assess_url_risk, get_requestscope_report]
  instructions:
    - Use claimedOrganisation when a message claims to represent a known brand or service.
    - Include only brief non-sensitive messageContext that helps interpret the link.
    - Private, local, non-HTTP, and unsafe redirect targets are blocked.
    - Describe the observed evidence and limitations with the rating.
    - Never describe a low rating as proof that a URL is safe.
    - Set externalReputation only after explicit consent; Google Web Risk and PhishTank receive full original/final URLs while Cloudflare receives hostnames only.
x-availability: Open for testing. Limited requests.
paths:
  /api:
    get:
      operationId: discoverRequestScopeApi
      summary: Discover RequestScope interfaces and endpoints
      responses:
        '200': { description: Service metadata and endpoint catalogue, content: { application/json: { schema: { $ref: '#/components/schemas/ApiDiscovery' } } } }
    head:
      operationId: probeRequestScopeApi
      summary: Availability probe without a response body
      responses:
        '200': { description: The GET headers with an empty body }
  /api/health:
    get:
      operationId: getRequestScopeHealth
      summary: Check service and reputation-provider availability
      responses:
        '200': { description: Service health and provider configuration, content: { application/json: { schema: { $ref: '#/components/schemas/HealthResponse' } } } }
    head:
      operationId: probeRequestScopeHealth
      summary: Health probe without a response body
      responses:
        '200': { description: The GET headers with an empty body }
  /api/scans:
    post:
      operationId: traceRequest
      summary: Create a complete RequestScope trace
      description: Returns DNS, redirects, HTTP evidence, page security signals, dependencies, optional dependency mapping, findings, opt-in Google Web Risk, PhishTank and Cloudflare DNS reputation, and a stored report ID.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ScanRequest' }
            example: { url: 'https://example.com', mapDependencies: true }
      responses:
        '201': { description: Completed or partial trace report, headers: { Location: { schema: { type: string }, description: Retrieval path of the stored report }, 'X-RequestScope-Recent-Observation': { schema: { type: string, enum: [fresh, reused] }, description: Whether this hashed client reused a five-minute observation } }, content: { application/json: { schema: { $ref: '#/components/schemas/ScanReport' } } } }
        '400': { description: Invalid input }
        '403': { description: Disallowed or non-public target }
        '429': { description: Request limit reached }
  /api/scans/stream:
    post:
      operationId: streamRequestTrace
      summary: Create a trace with newline-delimited progress events
      description: Validates and meters the submission before any byte is streamed, then emits newline-delimited progress events ending with the complete report. Failures discovered after streaming starts — inconclusive DNS resolution, blocked redirects, budget exhaustion — are delivered in-band as error events rather than HTTP status codes.
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/ScanRequest' } } }
      responses:
        '200': { description: NDJSON progress events ending with the complete report, content: { application/x-ndjson: { schema: { type: string } } } }
        '400': { description: Invalid input before streaming starts }
        '403': { description: Disallowed browser origin or non-public target before streaming starts }
        '429': { description: Request limit reached before streaming starts, headers: { Retry-After: { schema: { type: integer }, description: Seconds to wait before retrying } } }
  /api/scans/{reportId}:
    get:
      operationId: getRequestScopeReport
      summary: Retrieve an unexpired trace report
      description: Stored reports are immutable, so each response carries a strong ETag built from the report ID and conditional requests with If-None-Match answer 304 without a body. A missing or expired ID is a 404 without consuming retrieval quota. Existing reports, including 304 responses, consume the daily retrieval quota.
      parameters:
        - { name: reportId, in: path, required: true, schema: { type: string, pattern: '^[A-Za-z0-9_-]{16}$' } }
      responses:
        '200': { description: Stored trace report, headers: { ETag: { schema: { type: string }, description: Strong validator, the quoted report ID } }, content: { application/json: { schema: { $ref: '#/components/schemas/ScanReport' } } } }
        '304': { description: Not modified; the client copy is still current, headers: { ETag: { schema: { type: string } } } }
        '404': { description: Report not found or expired }
        '429': { description: Request limit reached }
      head:
        operationId: probeRequestScopeReport
        summary: Report-availability probe without a response body
        parameters:
          - { name: reportId, in: path, required: true, schema: { type: string, pattern: '^[A-Za-z0-9_-]{16}$' } }
        responses:
          '200': { description: The GET headers with an empty body }
          '404': { description: Report not found or expired }
          '429': { description: Retrieval quota reached for an existing report; missing IDs are 404 without consuming quota }
  /api/scans/{reportId}/export:
    get:
      operationId: exportRequestScopeReport
      summary: Download an unexpired trace report as formatted JSON
      description: Carries the same immutable-report ETag and If-None-Match behaviour as the report retrieval endpoint.
      parameters:
        - { name: reportId, in: path, required: true, schema: { type: string, pattern: '^[A-Za-z0-9_-]{16}$' } }
      responses:
        '200': { description: JSON report attachment, content: { application/json: { schema: { $ref: '#/components/schemas/ScanReport' } } } }
        '304': { description: Not modified; the client copy is still current, headers: { ETag: { schema: { type: string } } } }
        '404': { description: Report not found or expired }
        '429': { description: Request limit reached }
      head:
        operationId: probeRequestScopeExport
        summary: Export-availability probe without a response body
        parameters:
          - { name: reportId, in: path, required: true, schema: { type: string, pattern: '^[A-Za-z0-9_-]{16}$' } }
        responses:
          '200': { description: The GET headers with an empty body, including Content-Disposition attachment }
          '404': { description: Report not found or expired }
          '429': { description: Retrieval quota reached for an existing report; missing IDs are 404 without consuming quota }
  /api/v1/url-risk:
    post:
      operationId: assessUrlRisk
      summary: Trace and assess a public URL
      description: Returns a bounded, evidence-backed assessment with optional Google Web Risk, PhishTank and Cloudflare malware-filtering DNS observations. A low rating is not a declaration that a URL is safe.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UrlRiskRequest'
            example:
              url: https://example.com/account/verify
              claimedOrganisation: Example
              messageContext: Account verification email
      responses:
        '200':
          description: Completed assessment
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UrlRiskAssessment'
        '400':
          description: Invalid input
        '401':
          description: Invalid API credential when bearer authentication is configured
        '403':
          description: Disallowed or non-public target
        '429':
          description: Request limit reached
  /mcp/v2:
    post:
      operationId: invokeRequestScopeMcp
      summary: Invoke the RequestScope Streamable HTTP MCP endpoint
      description: JSON-RPC MCP endpoint for trace_request, assess_url_risk, and get_requestscope_report. Intentionally open for testing when COPILOT_API_KEY is unset; tool calls are bounded by MCP_DAILY_LIMIT (25) and mapped traces cost two units. Handshake and discovery are read-only. A bearer credential is required only when COPILOT_API_KEY is configured. A tool call that carries params._meta.progressToken and accepts text/event-stream receives notifications/progress events while the trace runs, followed by the ordinary JSON-RPC response as the final message event; without a progressToken the response is a single JSON document.
      security:
        - {}
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/McpRequest' }
      responses:
        '200':
          description: JSON-RPC result or error. Single document by default; an event stream of progress notifications ending with the response when the call supplied a progressToken.
          content:
            application/json: { schema: { type: object, additionalProperties: true } }
            text/event-stream: { schema: { type: string, description: 'message events; each data payload is a JSON-RPC notification or response' } }
        '202': { description: JSON-RPC notification accepted }
        '400': { description: Invalid JSON-RPC request }
        '401': { description: Invalid API credential when bearer authentication is configured }
        '415': { description: Content-Type must be application/json }
        '429': { description: Tool-call request limit reached }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: Required when COPILOT_API_KEY is configured on the Worker.
  schemas:
    ScanRequest:
      type: object
      additionalProperties: false
      required: [url]
      properties:
        url: { type: string, format: uri, maxLength: 2048 }
        mapDependencies: { type: boolean, default: false, description: Inspect bounded page dependencies and relationships. The web UI explicitly enables this for its full map view. }
        mobileUserAgent: { type: boolean, default: false, description: Request the target as mobile Safari instead of the RequestScope identity. Some sites serve different content per device; report.observation.deviceProfile records which profile ran. The observer stays at the Cloudflare edge either way. }
        claimedOrganisation: { type: string, maxLength: 120, description: Organisation the surrounding message claims to represent, used by the URL-risk assessment included in the trace. }
        messageContext: { type: string, maxLength: 1000, description: Brief non-sensitive context such as Password reset email. }
        externalReputation: { type: boolean, default: false, description: Set true only after the user agrees that the original and final URL including query values may be sent to Google Web Risk and PhishTank; Cloudflare's malware-filtering DNS receives hostnames only. }
    ApiDiscovery:
      type: object
      required: [service, version, sourceRevision, databaseSchemaVersion, website, endpoints, documentation]
      properties:
        service: { type: string }
        version: { type: string }
        sourceRevision: { type: string }
        databaseSchemaVersion: { type: integer, enum: [1] }
        website: { type: string, format: uri }
        endpoints: { type: object, additionalProperties: { type: string } }
        documentation:
          type: object
          required: [openapi, mcpConnector, privacy]
          properties:
            openapi: { type: string }
            mcpConnector: { type: string }
            privacy: { type: string }
    HealthResponse:
      type: object
      required: [ok, service, version, sourceRevision, databaseSchemaVersion, protection, reputationProviders, time]
      properties:
        ok: { type: boolean }
        service: { type: string }
        version: { type: string }
        sourceRevision: { type: string }
        databaseSchemaVersion: { type: integer, enum: [1] }
        protection: { type: string }
        reputationProviders:
          type: object
          required: [googleWebRisk, phishTank, cloudflareFamilyDns]
          properties:
            googleWebRisk: { type: boolean }
            phishTank: { type: boolean }
            cloudflareFamilyDns: { type: boolean }
        time: { type: string, format: date-time }
    ScanReport:
      type: object
      required: [schemaVersion, id, requestedUrl, normalizedUrl, hostname, status, createdAt, expiresAt, totalDurationMs, observation, dns, http, dependencies, findings, summary, coverage, provenance]
      properties:
        schemaVersion: { type: integer, enum: [1] }
        id: { type: string }
        requestedUrl: { type: string }
        normalizedUrl: { type: string }
        finalUrl: { type: string, nullable: true }
        hostname: { type: string }
        status: { type: string, enum: [complete, partial, failed] }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }
        totalDurationMs: { type: integer, minimum: 0 }
        observation: { type: object, additionalProperties: true }
        dns: { type: object, additionalProperties: true }
        http: { type: object, additionalProperties: true }
        pageSecuritySignals: { type: object, additionalProperties: true }
        dependencies: { type: object, additionalProperties: true }
        dependencyMap: { type: object, additionalProperties: true }
        urlRisk: { $ref: '#/components/schemas/UrlRiskAssessment' }
        findings: { type: array, items: { type: object, additionalProperties: true } }
        summary: { type: object, additionalProperties: true }
        coverage: { $ref: '#/components/schemas/ScanCoverage' }
        provenance: { $ref: '#/components/schemas/ReportProvenance' }
        reusedRecentObservation: { type: boolean, description: True on a create or stream response when the same hashed client reused a five-minute observation. Not persisted in D1. }
    ScanCoverage:
      type: object
      required: [status, budget, phases]
      properties:
        status: { type: string, enum: [complete, partial, failed, unavailable, skipped] }
        budget: { type: object, additionalProperties: true }
        phases:
          type: object
          required: [core, dependencies, reputation]
          properties:
            core: { $ref: '#/components/schemas/PhaseCoverage' }
            dependencies: { $ref: '#/components/schemas/PhaseCoverage' }
            reputation: { $ref: '#/components/schemas/PhaseCoverage' }
    PhaseCoverage:
      type: object
      required: [status, attempted, successful, failed, skipped, bytesInspected, truncated, durationMs]
      properties:
        status: { type: string, enum: [complete, partial, failed, unavailable, skipped] }
        attempted: { type: integer, minimum: 0 }
        successful: { type: integer, minimum: 0 }
        failed: { type: integer, minimum: 0 }
        skipped: { type: integer, minimum: 0 }
        bytesInspected: { type: integer, minimum: 0 }
        truncated: { type: boolean }
        durationMs: { type: integer, minimum: 0 }
        detail: { type: string }
    ReportProvenance:
      type: object
      required: [apiVersion, sourceRevision, reportSchemaVersion, databaseSchemaVersion]
      properties:
        apiVersion: { type: string }
        sourceRevision: { type: string }
        reportSchemaVersion: { type: integer, enum: [1] }
        databaseSchemaVersion: { type: integer, enum: [1] }
    McpRequest:
      type: object
      additionalProperties: false
      required: [jsonrpc, method]
      properties:
        jsonrpc: { type: string, enum: ['2.0'] }
        id: { oneOf: [{ type: string }, { type: integer }, { type: 'null' }] }
        method: { type: string, enum: [initialize, ping, tools/list, tools/call] }
        params: { type: object, additionalProperties: true }
    UrlRiskRequest:
      type: object
      required: [url]
      additionalProperties: false
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
        mobileUserAgent:
          type: boolean
          default: false
          description: Request the target as mobile Safari instead of the RequestScope identity, exposing device-specific content. The assessment records which profile was used via deviceProfile.
        claimedOrganisation:
          type: string
          maxLength: 120
          description: Organisation the surrounding message claims to represent.
        messageContext:
          type: string
          maxLength: 1000
          description: Brief non-sensitive context such as Password reset email.
        externalReputation:
          type: boolean
          default: false
          description: Set true only after the user agrees that the original and final URL including query values may be sent to Google Web Risk and PhishTank; Cloudflare's malware-filtering DNS receives hostnames only.
    UrlRiskAssessment:
      type: object
      required: [schemaVersion, verdict, riskScore, confidence, summary, requestedUrl, finalUrl, traceId, reportPath, claimedOrganisation, services, findings, reputation, limitations]
      properties:
        schemaVersion: { type: integer, enum: [1] }
        verdict: { type: string, enum: [low, medium, high] }
        riskScore: { type: integer, minimum: 0, maximum: 100 }
        confidence: { type: string, enum: [high, medium] }
        summary: { type: string }
        requestedUrl: { type: string }
        finalUrl: { type: string, nullable: true }
        traceId: { type: string }
        reportPath: { type: string }
        claimedOrganisation: { type: string, nullable: true }
        deviceProfile: { type: string, enum: [desktop, mobile], description: Request identity the observation used. }
        services:
          type: array
          items:
            type: object
            properties:
              hostname: { type: string }
              organisation: { type: string }
              category: { type: string }
        findings:
          type: array
          items:
            $ref: '#/components/schemas/RiskFinding'
        reputation:
          $ref: '#/components/schemas/ReputationAssessment'
        limitations:
          type: array
          items: { type: string }
    RiskFinding:
      type: object
      required: [code, severity, score, title, detail, evidence, confidence, source]
      properties:
        code: { type: string }
        severity: { type: string, enum: [low, medium, high] }
        score: { type: integer, minimum: 0 }
        title: { type: string }
        detail: { type: string }
        evidence: { type: object, additionalProperties: true }
        confidence: { type: string, enum: [high, medium] }
        source: { type: string, enum: [requestscope, page_observation, reputation_provider] }
    ReputationAssessment:
      type: object
      required: [status, detail, consentRequired, providers]
      properties:
        status: { type: string, enum: [matched, not_listed, partial, unavailable, not_configured, not_requested] }
        detail: { type: string }
        consentRequired: { type: boolean, enum: [true] }
        providers:
          type: array
          items:
            type: object
            required: [provider, target, hostname, status, threatTypes, detail, checkedAt, advisoryUrl, attribution]
            properties:
              provider: { type: string, enum: [google_web_risk, phishtank, cloudflare_family_dns] }
              target: { type: string, enum: [requested, final] }
              hostname: { type: string }
              status: { type: string, enum: [matched, not_listed, inconclusive, unavailable, quota_limited, not_configured] }
              threatTypes: { type: array, items: { type: string } }
              detail: { type: string }
              checkedAt: { type: string, format: date-time }
              expiresAt: { type: string, format: date-time, nullable: true }
              advisoryUrl: { type: string, format: uri }
              attribution: { type: string }
