openapi: 3.0.3
info:
  title: Wireview API
  description: |
    Domain & IP reconnaissance platform. Solo, no 3rd-party paid APIs. Free tier 50/day.

    Auth: `X-API-Key` (analyze/report/bulk/compare and exports) or `Authorization: Bearer <JWT>`
    (profile/keys/usage/history/cases/monitor). Public endpoints (whois/dns/iplookup/analyze)
    are rate-limited per IP (60/min, `X-RateLimit-*` headers, `Retry-After` on 429) and
    validate input (`400` on malformed domain/IP).
  version: 1.2.0
  contact:
    name: CodeCaptives
    url: https://codecaptives.com
servers:
  # PRODUCTION FIRST, AND THIS IS NOT COSMETIC.
  #
  # Swagger UI selects the FIRST server in this list as its default and points
  # every "Try it out" button at it. With localhost listed first, everyone who
  # opened this page had "Execute" silently aimed at their own machine — where
  # nothing is listening on 8080 except a developer's local server. The docs
  # looked broken in a way that was entirely the spec's fault.
  - url: https://api.codecaptives.com
    description: Production
  # Local is kept last so a developer can still switch to it, but it can never
  # be the default a stranger lands on.
  - url: http://localhost:8080
    description: Local (development only)
paths:
  # NOT /healthz.
  #
  # The exact path /healthz is RESERVED by Cloud Run's front end: the request is
  # answered with a Google-generated 404 and never reaches the container, so it
  # cannot work here no matter what the app registers. Documenting it would be
  # documenting an endpoint that provably 404s. /health is the working alias and
  # is what the deploy scripts check. See DEPLOY.md, "Known platform quirks".
  /health:
    get:
      summary: Liveness
      responses:
        '200': { description: OK }
  /readyz:
    get:
      summary: Readiness (DB check)
      responses:
        '200': { description: Ready }
        '503': { description: Not ready }
  /metrics:
    get:
      summary: Metrics (JSON)
      responses:
        '200': { description: Metrics }
  /api/auth/register:
    post:
      summary: Register
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [email, password], properties: { email: {type: string}, password: {type: string, minLength: 8} } }
      responses:
        '201': { description: Created }
        '409': { description: Email exists }
  /api/auth/login:
    post:
      summary: Login
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [email, password], properties: { email: {type: string}, password: {type: string} } }
      responses:
        '200': { description: JWT + cookie + active apiKey (full value, shown once) }
        '401': { description: Invalid credentials }
  /api/auth/profile:
    get:
      summary: Profile (JWT). API keys are masked (`wv_993…1f17`, `masked: true`); full values are returned only at creation/login.
      security: [{ bearerAuth: [] }]
      responses:
        '200': { description: User + masked apiKeys }
  /api/auth/logout:
    post:
      summary: Clear the session cookie (JWT not required; the cookie IS the credential)
      description: |
        Destroys the server-side session and expires the cookie. Takes no body and
        no Authorization header — the session cookie is the only thing being
        presented, so requiring a bearer token here would be circular.

        Safe to call when already logged out; it is idempotent.
      responses:
        '200': { description: Session cleared }
  /api/auth/account:
    delete:
      summary: Permanently delete the authenticated account and all its data
      description: |
        Erases the account and everything referencing it — API keys, usage
        records, investigations, triage notes, finding states, monitors,
        alerts and failed-login counters — in a single transaction. There is no
        undo and no soft-delete.

        Always targets the caller's own account. There is no path parameter, so
        an authenticated caller cannot name anyone else's account.

        An issued JWT stays cryptographically valid until it expires, but
        resolves to nothing afterwards because the user row it points at is
        gone. API keys are revoked immediately by the same transaction.

        Returns 204 if the account was already absent, which is a success: the
        requested end state holds.
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Account deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean }
                  removed:
                    type: object
                    description: Row counts removed per table. No row content, no email address.
                    additionalProperties: { type: integer }
        '204': { description: Account was already absent }
        '401': { description: Not authenticated }
  /api/auth/keys:
    post:
      summary: Create API key (JWT). Full key value returned once.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, properties: { name: {type: string}, tier: {type: string} } }
      responses:
        '201': { description: Key created (full value in response) }
  /api/auth/keys/{id}:
    delete:
      summary: Revoke API key (JWT)
      security: [{ bearerAuth: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: integer } }]
      responses:
        '204': { description: Revoked }
  /api/auth/webhook:
    put:
      summary: Set per-user alert webhook URL (JWT; empty disables)
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, properties: { webhook_url: {type: string} } }
      responses:
        '200': { description: Saved }
  /api/wireview/cases/{invId}/findings/{findingId}:
    patch:
      summary: Set triage status on a finding (JWT)
      description: |
        One of the four allowed states. Anything else is rejected with
        `400 INVALID_STATUS` — the list is validated against an allowlist rather
        than trusted, so a typo cannot write an arbitrary string into the
        database.
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: invId, in: path, required: true, schema: { type: integer } }
        - { name: findingId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [New, Reviewed, Dismissed, Confirmed] }
      responses:
        '200': { description: Status updated }
        '400': { description: Status not in the allowed set }
        '404': { description: Investigation not found or not owned by this user }
  /api/auth/usage:
    get:
      summary: Usage for today (JWT)
      security: [{ bearerAuth: [] }]
      responses:
        '200': { description: Usage counters }
  /api/wireview/whois:
    get:
      summary: Registration record (public, rate-limited, validated)
      description: |
        Returns the registration record for a domain, from RDAP where the
        registry publishes it and port-43 WHOIS otherwise. Both paths return the
        same shape.

        `parsed.confidence` is derived from what was actually observed: 0 when
        nothing could be read, 0.5-0.95 otherwise. A 404 means no record was
        reachable, which is a valid answer and is reported rather than being
        returned as an empty success.
      parameters: [{ name: domain, in: query, required: true, schema: {type: string} }]
      responses:
        '200': { description: Registration record }
        '404': { description: No registration record could be read }
        '400': { description: Invalid domain }
        '429': { description: Rate limited }
  /api/wireview/dns:
    get:
      summary: DNS (public, rate-limited, validated)
      parameters: [{ name: domain, in: query, required: true, schema: {type: string} }]
      responses:
        '200': { description: DNS }
        '400': { description: Invalid domain }
        '429': { description: Rate limited }
  /api/wireview/iplookup:
    get:
      summary: IP lookup (public, rate-limited, validated IPv4/IPv6)
      parameters: [{ name: ip, in: query, required: true, schema: {type: string} }]
      responses:
        '200': { description: IP }
        '400': { description: Invalid IP }
        '429': { description: Rate limited }
  /api/wireview/analyze:
    get:
      summary: Full investigation (anonymous = depth 1, X-API-Key = full)
      security: [{ ApiKey: [] }]
      parameters: [{ name: target, in: query, required: true, schema: {type: string} }]
      responses:
        '200': { description: Investigation }
        '400': { description: Invalid target }
        '401': { description: Missing API key }
        '429': { description: Rate limited }
  /api/wireview/analyze-stream:
    get:
      summary: Investigation as server-sent events (progress + final payload)
      security: [{ ApiKey: [] }]
      parameters: [{ name: target, in: query, required: true, schema: {type: string} }]
      responses:
        '200':
          description: text/event-stream
          content:
            text/event-stream:
              schema: { type: string }
        '400': { description: Invalid target }
        '429': { description: Rate limited }
  /api/wireview/report:
    get:
      summary: Report export
      security: [{ ApiKey: [] }]
      parameters:
        - { name: target, in: query, required: true, schema: {type: string} }
        - { name: format, in: query, schema: {type: string, enum: [json, pdf, csv, graphml, gexf], default: json} }
      responses:
        '200': { description: Report }
  /api/wireview/bulk:
    post:
      summary: Bulk investigation (protected, max 10)
      security: [{ ApiKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [targets], properties: { targets: {type: array, items: {type: string}}, format: {type: string, enum: [json, csv], default: json} } }
      responses:
        '200': { description: Bulk results }
  /api/wireview/compare:
    post:
      summary: Compare mode (protected, max 10 targets)
      security: [{ ApiKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [targets], properties: { targets: {type: array, items: {type: string}}, domains: {type: array, items: {type: string}} } }
      responses:
        '200': { description: Comparison results with shared infrastructure }
  /api/wireview/investigations:
    get:
      summary: Investigation history (JWT, user-scoped)
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: target, in: query, schema: {type: string} }
        - { name: limit, in: query, schema: {type: integer, default: 20} }
      responses:
        '200': { description: History page }
  /api/wireview/investigations/compare:
    get:
      summary: Diff two stored investigations (JWT)
      security: [{ bearerAuth: [] }]
      parameters:
        - { name: from, in: query, required: true, schema: {type: integer} }
        - { name: to, in: query, required: true, schema: {type: integer} }
      responses:
        '200': { description: Diff }
  /api/wireview/investigations/{id}:
    get:
      summary: Single stored investigation (JWT)
      security: [{ bearerAuth: [] }]
      parameters: [{ name: id, in: path, required: true, schema: {type: integer} }]
      responses:
        '200': { description: Investigation }
  /api/wireview/cases/{invId}/notes:
    get:
      summary: Case notes (JWT)
      security: [{ bearerAuth: [] }]
      parameters: [{ name: invId, in: path, required: true, schema: {type: integer} }]
      responses:
        '200': { description: Notes }
    post:
      summary: Add case note (JWT)
      security: [{ bearerAuth: [] }]
      parameters: [{ name: invId, in: path, required: true, schema: {type: integer} }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [kind, body], properties: { kind: {type: string}, ref_id: {type: string}, body: {type: string} } }
      responses:
        '201': { description: Created }
  /api/wireview/cases/{invId}/findings:
    get:
      summary: Triage finding states (JWT)
      security: [{ bearerAuth: [] }]
      parameters: [{ name: invId, in: path, required: true, schema: {type: integer} }]
      responses:
        '200': { description: Finding states }
  /api/wireview/nl:
    post:
      summary: Natural-language query over an investigation (JWT)
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [query], properties: { query: {type: string}, invId: {type: integer} } }
      responses:
        '200': { description: Parsed answer }
  /api/monitor:
    get:
      summary: List monitors (JWT)
      security: [{ bearerAuth: [] }]
      responses:
        '200': { description: Monitors }
    post:
      summary: Create monitor (JWT)
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [target], properties: { target: {type: string}, interval_hours: {type: integer, default: 24} } }
      responses:
        '201': { description: Created }
  /api/monitor/{id}:
    delete:
      summary: Delete monitor (JWT)
      security: [{ bearerAuth: [] }]
      parameters: [{ name: id, in: path, required: true, schema: {type: integer} }]
      responses:
        '204': { description: Deleted }
  /api/monitor/alerts:
    get:
      summary: Monitor alerts (JWT)
      security: [{ bearerAuth: [] }]
      responses:
        '200': { description: Alerts }
components:
  securitySchemes:
    ApiKey: { type: apiKey, in: header, name: X-API-Key }
    bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
