openapi: 3.1.0
info:
  title: Moatgator Public API
  version: 1.0.0
  summary: REST API for CI, scripts and integrations.
  description: |
    The public API v1 of Moatgator. **This file is the source of truth**: the server validates requests and (outside production)
    responses against it, `src/publicapi/types.gen.ts` is generated from it, and the reference page is built from it.

    ## Authentication
    Send an API key as `Authorization: Bearer mg_live_...` (or `mg_test_...`). A key belongs to **one app** and carries scopes
    (`builds:write`, `builds:read`, `scans:read`, `threats:read`, `attest:verify`); ask only for what the integration needs.
    For CI there is no key to store: a GitHub Actions job exchanges its own OIDC token for a short-lived access token
    (`POST /v1/auth/oidc`, valid 10 minutes) and sends it as `Authorization: Bearer <token>` in place of the key. The app must have a
    **trust rule** that names the repository and ref (or environment) allowed to do so; with no rule, nobody gets in. Access tokens
    never carry device scopes (`attest:verify`, `events:ingest`).

    ## Errors
    Errors use [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) `application/problem+json`. `code` is a stable machine-readable
    identifier; `detail` is for people.

    ## Rate limits
    Per API key, by the plan of the app. Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`
    (seconds until the window resets); a `429` also carries `Retry-After`.

    ## Idempotency
    `POST` operations that create something accept an `Idempotency-Key` header (8 to 128 characters). Repeating the same key with
    the same body within 24 hours returns the original response (with `Idempotent-Replayed: true`); the same key with a different
    body is a `422`.

    ## Pagination
    List endpoints use opaque cursors: pass `next_cursor` from the previous page as `cursor`. Never build or parse a cursor.

    ## Versioning
    The major version is in the path (`/v1`). Additive changes (new fields, new endpoints) are not breaking. Deprecated operations
    send `Deprecation` and `Sunset` headers at least 6 months before removal. See `docs/api/versioning.md`.
  contact: { name: Moatgator, url: "https://moatgator.com", email: "support@moatgator.com" }
  license: { name: Proprietary, identifier: LicenseRef-Moatgator }
servers:
  - url: https://api.moatgator.com
    description: Production
  - url: http://localhost:4000
    description: Local development
tags:
  - name: Auth
    description: Keyless authentication for CI.
  - name: Builds
    description: Register builds and upload their artifacts.
  - name: Threats
    description: Security events detected on devices.
  - name: Scans
    description: Static analysis results.
  - name: Attest
    description: App and device attestation.
security:
  - apiKey: []
  - oidc: []
# Reutilizado por todas as respostas autenticadas (âncora YAML): o estado do limite de uso da chave.
x-rate-limit-headers: &rl
  RateLimit-Limit: { $ref: "#/components/headers/RateLimit-Limit" }
  RateLimit-Remaining: { $ref: "#/components/headers/RateLimit-Remaining" }
  RateLimit-Reset: { $ref: "#/components/headers/RateLimit-Reset" }
paths:
  /v1/auth/oidc:
    post:
      operationId: exchangeOidcToken
      tags: [Auth]
      summary: Exchange a CI OIDC token for an access token
      description: |
        In a GitHub Actions job with `permissions: id-token: write`, request an OIDC token with audience equal to this API's base URL and send it
        here with the id of the app. If a trust rule of the app matches the token (issuer, repository, ref or environment), the response is a
        short-lived access token (10 minutes) with the scopes granted by the matching rules. An OIDC token can be exchanged **once**. The same
        `401` answers every refusal (unknown app, no rule, wrong repository, expired, replayed token): the reason is recorded in the
        organization's audit trail, not revealed to the caller.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OidcExchangeRequest" }
      responses:
        "200":
          description: The access token.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OidcExchangeResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /v1/whoami:
    get:
      operationId: whoami
      tags: [Auth]
      summary: Who am I
      description: |
        Identifies the credential (API key or OIDC access token) without needing any scope: the app it belongs to, the plan, the scopes and
        when it expires. Used by `moatgator whoami` and `moatgator doctor`. Never returns the secret.
      responses:
        "200":
          description: The credential.
          headers: *rl
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Whoami" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /v1/builds:
    post:
      operationId: createBuild
      tags: [Builds]
      summary: Register a build
      description: |
        Registers a build of the app that owns the API key. Declare the artifact (`sizeBytes`, `sha256`) to receive a short-lived
        signed `uploadUrl` for the file. Requires scope `builds:write`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BuildCreate" }
            examples:
              withArtifact:
                summary: Build with an artifact to upload
                value: { platform: android, version: "3.4.1", commit: "9fceb02", artifact: { sizeBytes: 48211023, sha256: "3f9c1a7e5b2d4086a1c3e5f70912b4d63f9c1a7e5b2d4086a1c3e5f70912b4d6" } }
      responses:
        "201":
          description: Build registered.
          headers: *rl
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Build" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /v1/builds/{id}:
    get:
      operationId: getBuild
      tags: [Builds]
      summary: Get a build
      description: Requires scope `builds:read`. A build of another app is a `404`.
      parameters:
        - $ref: "#/components/parameters/BuildId"
      responses:
        "200":
          description: The build.
          headers: *rl
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Build" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /v1/uploads/{token}:
    put:
      operationId: uploadBuildArtifact
      tags: [Builds]
      summary: Upload a build artifact
      description: |
        Uploads the file declared when the build was created, as the raw request body (`application/octet-stream`). The URL is
        the signed `uploadUrl` returned by `createBuild`; the token in the path is the credential, so it needs no `Authorization`
        header and expires in 15 minutes. The size and SHA-256 must match what was declared.
      security: []
      parameters:
        - { name: token, in: path, required: true, schema: { type: string, minLength: 20, maxLength: 600 }, description: Opaque signed upload token. }
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema: { type: string, format: binary }
      responses:
        "200":
          description: Artifact stored.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Build" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /v1/apps/{id}/threats:
    get:
      operationId: listThreats
      tags: [Threats]
      summary: List security events of an app
      description: Newest first. Requires scope `threats:read`; the key must belong to this app. Device identifiers are never returned.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, minLength: 3, maxLength: 64 } }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - { name: detection, in: query, required: false, schema: { type: string, enum: [root_jailbreak, emulator, debugger, hooking, tamper, overlay, accessibility_abuse, screen_capture, remote_access] } }
      responses:
        "200":
          description: A page of events.
          headers: *rl
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ThreatList" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /v1/scans/{id}:
    get:
      operationId: getScan
      tags: [Scans]
      summary: Get a scan
      description: Requires scope `scans:read`. A scan of another app is a `404`.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, pattern: "^scn_[A-Za-z0-9_-]{6,40}$" } }
      responses:
        "200":
          description: The scan.
          headers: *rl
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Scan" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /v1/attest/verify:
    post:
      operationId: verifyAttest
      tags: [Attest]
      summary: Verify an attestation token
      description: |
        Checks the attestation token an app sent to your backend. Requires scope `attest:verify`. **The attestation backend is a
        stub in this version** (`engine: stub`): the contract is stable, the verdicts are not yet backed by real platform checks.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AttestRequest" }
      responses:
        "200":
          description: The verdict.
          headers: *rl
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AttestResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: mg_live_<id>_<secret>
      description: API key of one app, with scopes. Keys are shown once when created.
    oidc:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Short-lived access token (10 minutes) obtained from `POST /v1/auth/oidc` with the OIDC token of a CI job. Scopes come from the app's trust rules.
  parameters:
    BuildId:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: "^bld_[A-Za-z0-9_-]{6,40}$" }
    Limit:
      name: limit
      in: query
      required: false
      description: Page size.
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    Cursor:
      name: cursor
      in: query
      required: false
      description: Opaque cursor from `next_cursor` of the previous page.
      schema: { type: string, minLength: 1, maxLength: 512 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Makes the request safe to retry. 8 to 128 characters; keep it unique per operation.
      schema: { type: string, minLength: 8, maxLength: 128, pattern: "^[A-Za-z0-9_.:-]+$" }
  headers:
    RateLimit-Limit: { description: Requests allowed per window for this key., schema: { type: integer, minimum: 1 } }
    RateLimit-Remaining: { description: Requests left in the current window., schema: { type: integer, minimum: 0 } }
    RateLimit-Reset: { description: Seconds until the window resets., schema: { type: integer, minimum: 0 } }
    Retry-After: { description: Seconds to wait before retrying., schema: { type: integer, minimum: 1 } }
  responses:
    BadRequest: { description: "Malformed request.", content: { application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } } } }
    Unauthorized: { description: "Missing, malformed, revoked or expired API key.", content: { application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } } } }
    Forbidden: { description: "The key lacks the required scope, or the signed URL is invalid or expired.", content: { application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } } } }
    NotFound: { description: "The resource does not exist (or belongs to another app).", content: { application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } } } }
    Conflict: { description: "Conflicts with the current state (for example an idempotent request still in progress).", content: { application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } } } }
    PayloadTooLarge: { description: "The body is larger than allowed.", content: { application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } } } }
    Unprocessable: { description: "Well-formed but invalid (validation failed, or the idempotency key was reused with another body).", content: { application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } } } }
    TooManyRequests: { description: "Rate limit exceeded. See `Retry-After`.", headers: { Retry-After: { $ref: "#/components/headers/Retry-After" }, RateLimit-Limit: { $ref: "#/components/headers/RateLimit-Limit" }, RateLimit-Remaining: { $ref: "#/components/headers/RateLimit-Remaining" }, RateLimit-Reset: { $ref: "#/components/headers/RateLimit-Reset" } }, content: { application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } } } }
  schemas:
    Problem:
      type: object
      description: RFC 9457 problem details.
      required: [type, title, status, code]
      properties:
        type: { type: string, format: uri, examples: ["https://docs.moatgator.com/errors/not-found"] }
        title: { type: string }
        status: { type: integer, minimum: 400, maximum: 599 }
        detail: { type: string }
        instance: { type: string }
        code: { type: string, description: Stable machine-readable identifier., examples: [not_found] }
        errors:
          type: array
          description: Field-level validation errors.
          items:
            type: object
            required: [pointer, message]
            properties: { pointer: { type: string, examples: ["/version"] }, message: { type: string } }
        retryAfter: { type: integer, minimum: 1, description: Seconds to wait (rate limits). }
    Platform: { type: string, enum: [android, ios] }
    Sha256: { type: string, pattern: "^[0-9a-f]{64}$" }
    ArtifactDeclaration:
      type: object
      required: [sizeBytes, sha256]
      additionalProperties: false
      properties:
        sizeBytes: { type: integer, minimum: 1, maximum: 524288000 }
        sha256: { $ref: "#/components/schemas/Sha256" }
    BuildCreate:
      type: object
      required: [platform, version]
      additionalProperties: false
      properties:
        platform: { $ref: "#/components/schemas/Platform" }
        version: { type: string, minLength: 1, maxLength: 40, examples: ["3.4.1"] }
        commit: { type: [string, "null"], maxLength: 64 }
        artifact: { $ref: "#/components/schemas/ArtifactDeclaration" }
    Artifact:
      type: object
      required: [status, sizeBytes, sha256]
      properties:
        status: { type: string, enum: [pending, uploaded] }
        sizeBytes: { type: integer }
        sha256: { $ref: "#/components/schemas/Sha256" }
        uploadUrl: { type: string, format: uri, description: "Signed URL to PUT the file to; present while `status` is `pending`." }
        uploadUrlExpiresAt: { type: string, format: date-time }
    Build:
      type: object
      required: [id, appId, platform, version, status, engine, createdAt, artifact]
      properties:
        id: { type: string, pattern: "^bld_[A-Za-z0-9_-]{6,40}$" }
        appId: { type: string }
        platform: { $ref: "#/components/schemas/Platform" }
        version: { type: string }
        commit: { type: [string, "null"] }
        status: { type: string, enum: [succeeded] }
        engine: { type: string, enum: [preview], description: Builds are recorded in preview mode (no signed artifact pipeline yet). }
        createdAt: { type: string, format: date-time }
        artifact:
          oneOf:
            - { type: "null" }
            - { $ref: "#/components/schemas/Artifact" }
    Threat:
      type: object
      required: [id, detection, verdict, confidence, action, signals, occurredAt, count]
      properties:
        id: { type: string }
        detection: { type: string, enum: [root_jailbreak, emulator, debugger, hooking, tamper, overlay, accessibility_abuse, screen_capture, remote_access] }
        verdict: { type: string, enum: [clean, suspicious, compromised] }
        confidence: { type: number, minimum: 0, maximum: 1 }
        action: { type: string, enum: [monitor, warn, block, end_session] }
        signals: { type: array, items: { type: string } }
        platform: { oneOf: [{ type: "null" }, { $ref: "#/components/schemas/Platform" }] }
        appVersion: { type: [string, "null"] }
        buildId: { type: [string, "null"] }
        occurredAt: { type: string, format: date-time }
        count: { type: integer, minimum: 1, description: How many identical events were merged into this one. }
    ThreatList:
      type: object
      required: [data, hasMore, nextCursor]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Threat" } }
        hasMore: { type: boolean }
        nextCursor: { type: [string, "null"], description: "Pass as `cursor` to get the next page; null on the last page." }
    Finding:
      type: object
      required: [id, severity, title, detail, fix]
      properties:
        id: { type: string }
        severity: { type: string, enum: [low, medium, high, critical] }
        title: { type: string }
        detail: { type: string }
        fix: { type: string }
    Scan:
      type: object
      required: [id, appId, engine, grade, score, target, findings, createdAt]
      properties:
        id: { type: string, pattern: "^scn_[A-Za-z0-9_-]{6,40}$" }
        appId: { type: string }
        engine: { type: string, enum: [preview, static], description: "`static` = analysis of an uploaded package; `preview` = rules over declared metadata." }
        grade: { type: string, pattern: "^[A-F][+-]?$" }
        score: { type: integer, minimum: 0, maximum: 100 }
        target:
          type: object
          required: [platform, name, version]
          properties: { platform: { $ref: "#/components/schemas/Platform" }, name: { type: string }, version: { type: [string, "null"] } }
        findings: { type: array, items: { $ref: "#/components/schemas/Finding" } }
        createdAt: { type: string, format: date-time }
    Whoami:
      type: object
      required: [appId, plan, scopes, auth, expiresAt]
      properties:
        appId: { type: string }
        plan: { type: string, enum: [hatchling, indie, startup, scale, enterprise] }
        scopes: { type: array, items: { type: string } }
        auth: { type: string, enum: [api_key, oidc] }
        expiresAt: { type: [string, "null"], format: date-time }
    OidcExchangeRequest:
      type: object
      required: [token, appId]
      additionalProperties: false
      properties:
        token: { type: string, minLength: 20, maxLength: 8192, description: The OIDC token issued to the CI job. }
        appId: { type: string, pattern: "^app_[A-Za-z0-9_-]{6,40}$" }
    OidcExchangeResponse:
      type: object
      required: [accessToken, tokenType, expiresIn, scopes, appId]
      properties:
        accessToken: { type: string, description: "Send as `Authorization: Bearer <accessToken>`. Never log it." }
        tokenType: { type: string, enum: [Bearer] }
        expiresIn: { type: integer, description: Seconds until the token expires. }
        scopes: { type: array, items: { type: string, enum: [builds:write, builds:read, scans:read, threats:read] } }
        appId: { type: string }
    AttestRequest:
      type: object
      required: [token, nonce, platform]
      additionalProperties: false
      properties:
        token: { type: string, minLength: 8, maxLength: 8192 }
        nonce: { type: string, minLength: 8, maxLength: 128 }
        platform: { $ref: "#/components/schemas/Platform" }
    AttestResult:
      type: object
      required: [valid, verdict, reasons, engine]
      properties:
        valid: { type: boolean }
        verdict: { type: string, enum: [trusted, suspicious, untrusted] }
        reasons: { type: array, items: { type: string } }
        engine: { type: string, enum: [stub], description: The attestation backend is a stub in this version. }
