openapi: 3.1.0
info:
  title: rageshake.dev API
  version: "1.0.0"
  description: >
    Shake-to-report bug reporting for mobile apps. Ingest keys (rs_ing_) submit
    reports; management keys (rs_mgmt_) perform CRUD on projects, keys, and
    destinations. Errors follow Google AIP-193 with the shape
    { error: { code, message, status, details? } }. No pagination: list methods
    return all results.
servers:
  - url: https://rageshake.dev
    description: Production

security: []

tags:
  - name: Projects
  - name: API Keys
  - name: Destinations
  - name: Reports

paths:
  /api/v1/projects:
    get:
      tags: [Projects]
      summary: List projects
      description: Lists all projects for the authenticated account.
      operationId: listProjects
      security:
        - managementKey: []
      responses:
        "200":
          description: The account's projects.
          content:
            application/json:
              schema:
                type: object
                properties:
                  projects:
                    type: array
                    items: { $ref: "#/components/schemas/Project" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/PermissionDenied" }
    post:
      tags: [Projects]
      summary: Create project
      operationId: createProject
      security:
        - managementKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateProject" }
      responses:
        "201":
          description: The created project.
          content:
            application/json:
              schema:
                type: object
                properties:
                  project: { $ref: "#/components/schemas/Project" }
        "400": { $ref: "#/components/responses/InvalidArgument" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/PermissionDenied" }

  /api/v1/projects/{projectId}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Projects]
      summary: Get project
      operationId: getProject
      security:
        - managementKey: []
      responses:
        "200":
          description: The project.
          content:
            application/json:
              schema:
                type: object
                properties:
                  project: { $ref: "#/components/schemas/Project" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/PermissionDenied" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Projects]
      summary: Update project
      operationId: updateProject
      security:
        - managementKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateProject" }
      responses:
        "200":
          description: The updated project.
          content:
            application/json:
              schema:
                type: object
                properties:
                  project: { $ref: "#/components/schemas/Project" }
        "400": { $ref: "#/components/responses/InvalidArgument" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/PermissionDenied" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Projects]
      summary: Delete project
      operationId: deleteProject
      security:
        - managementKey: []
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/PermissionDenied" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/projects/{projectId}/keys:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [API Keys]
      summary: List API keys
      operationId: listApiKeys
      security:
        - managementKey: []
      responses:
        "200":
          description: The project's API keys (no secrets).
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKey" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/PermissionDenied" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [API Keys]
      summary: Create API key
      description: >
        Creates an ingest or management key. The plaintext secret is returned
        exactly once in the `secret` field and never again.
      operationId: createApiKey
      security:
        - managementKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateApiKey" }
      responses:
        "201":
          description: The created key plus its one-time secret.
          content:
            application/json:
              schema:
                type: object
                properties:
                  key: { $ref: "#/components/schemas/ApiKey" }
                  secret:
                    type: string
                    description: One-time plaintext key. Store it now.
                    example: rs_ing_9fA2...
        "400": { $ref: "#/components/responses/InvalidArgument" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/PermissionDenied" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/projects/{projectId}/keys/{keyId}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/KeyId"
    delete:
      tags: [API Keys]
      summary: Revoke API key
      description: Revokes (does not delete) the key by setting revokedAt.
      operationId: revokeApiKey
      security:
        - managementKey: []
      responses:
        "200":
          description: The revoked key.
          content:
            application/json:
              schema:
                type: object
                properties:
                  key: { $ref: "#/components/schemas/ApiKey" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/PermissionDenied" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/AlreadyExists" }

  /api/v1/projects/{projectId}/destinations:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Destinations]
      summary: List destinations
      operationId: listDestinations
      security:
        - managementKey: []
      responses:
        "200":
          description: The project's destinations (secrets redacted).
          content:
            application/json:
              schema:
                type: object
                properties:
                  destinations:
                    type: array
                    items: { $ref: "#/components/schemas/Destination" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/PermissionDenied" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Destinations]
      summary: Create destination
      operationId: createDestination
      security:
        - managementKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateDestination" }
      responses:
        "201":
          description: The created destination (secrets redacted).
          content:
            application/json:
              schema:
                type: object
                properties:
                  destination: { $ref: "#/components/schemas/Destination" }
        "400": { $ref: "#/components/responses/InvalidArgument" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/PermissionDenied" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/projects/{projectId}/destinations/{destinationId}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/DestinationId"
    get:
      tags: [Destinations]
      summary: Get destination
      operationId: getDestination
      security:
        - managementKey: []
      responses:
        "200":
          description: The destination (secrets redacted).
          content:
            application/json:
              schema:
                type: object
                properties:
                  destination: { $ref: "#/components/schemas/Destination" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/PermissionDenied" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Destinations]
      summary: Update destination
      description: Toggle `enabled` and/or replace `config` (validated by type).
      operationId: updateDestination
      security:
        - managementKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateDestination" }
      responses:
        "200":
          description: The updated destination (secrets redacted).
          content:
            application/json:
              schema:
                type: object
                properties:
                  destination: { $ref: "#/components/schemas/Destination" }
        "400": { $ref: "#/components/responses/InvalidArgument" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/PermissionDenied" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Destinations]
      summary: Delete destination
      operationId: deleteDestination
      security:
        - managementKey: []
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/PermissionDenied" }
        "404": { $ref: "#/components/responses/NotFound" }

  "/api/v1/projects/{projectId}/destinations/{destinationId}:test":
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/DestinationId"
    post:
      tags: [Destinations]
      summary: Test destination
      description: >
        AIP custom method. Decrypts and shape-checks the stored config, then
        runs the real adapter (Linear or webhook) against a synthetic report and
        returns the delivery outcome: `success`, `retryable` (transient), or
        `permanent` (will not succeed, e.g. bad credentials or an SSRF-blocked
        URL).
      operationId: testDestination
      security:
        - managementKey: []
      responses:
        "200":
          description: The delivery test result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  result: { $ref: "#/components/schemas/DeliveryResult" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/PermissionDenied" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/projects/{projectId}/reports:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      tags: [Reports]
      summary: List reports
      description: All reports for the project, newest first, each with its deliveries summarized by status.
      operationId: listReports
      security:
        - managementKey: []
      responses:
        "200":
          description: The project's reports.
          content:
            application/json:
              schema:
                type: object
                properties:
                  reports:
                    type: array
                    items: { $ref: "#/components/schemas/ReportSummary" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/PermissionDenied" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/projects/{projectId}/reports/{reportId}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
      - $ref: "#/components/parameters/ReportId"
    get:
      tags: [Reports]
      summary: Get report
      description: The full report plus per-delivery detail (status, attempts, errors).
      operationId: getReport
      security:
        - managementKey: []
      responses:
        "200":
          description: The report and its deliveries.
          content:
            application/json:
              schema:
                type: object
                properties:
                  report: { $ref: "#/components/schemas/Report" }
                  deliveries:
                    type: array
                    items: { $ref: "#/components/schemas/DeliveryDetail" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/PermissionDenied" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/reports:
    post:
      tags: [Reports]
      summary: Submit a report (ingest)
      description: >
        Multipart submission from the SDK. `payload` is a JSON part; `screenshot`
        is an optional PNG or JPEG file (max 10MB, validated by magic bytes).
        Requires an ingest key. Rate limited per key.
      operationId: createReport
      security:
        - ingestKey: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [payload]
              properties:
                payload:
                  $ref: "#/components/schemas/ReportPayload"
                screenshot:
                  type: string
                  format: binary
                  description: Optional PNG or JPEG screenshot, max 10MB.
      responses:
        "201":
          description: Report received.
          content:
            application/json:
              schema:
                type: object
                properties:
                  report:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      createdAt: { type: string, format: date-time }
        "400": { $ref: "#/components/responses/InvalidArgument" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/PermissionDenied" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/openapi:
    get:
      summary: OpenAPI document
      description: Returns this specification as YAML.
      operationId: getOpenapi
      responses:
        "200":
          description: The OpenAPI YAML document.
          content:
            application/yaml:
              schema: { type: string }

components:
  securitySchemes:
    ingestKey:
      type: http
      scheme: bearer
      description: Ingest key (rs_ing_). Submits reports only.
    managementKey:
      type: http
      scheme: bearer
      description: Management key (rs_mgmt_). Full CRUD.

  parameters:
    ProjectId:
      name: projectId
      in: path
      required: true
      schema: { type: string, format: uuid }
    KeyId:
      name: keyId
      in: path
      required: true
      schema: { type: string, format: uuid }
    DestinationId:
      name: destinationId
      in: path
      required: true
      schema: { type: string, format: uuid }
    ReportId:
      name: reportId
      in: path
      required: true
      schema: { type: string, format: uuid }

  responses:
    Unauthenticated:
      description: Missing, malformed, invalid, or revoked key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    PermissionDenied:
      description: Wrong key type for this endpoint.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource not found or not owned by the account.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    InvalidArgument:
      description: Request validation failed.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    AlreadyExists:
      description: Conflict (e.g. key already revoked).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    PayloadTooLarge:
      description: Screenshot exceeds the size limit.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Too many requests for this key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, status]
          properties:
            code:
              type: string
              example: INVALID_ARGUMENT
            message: { type: string }
            status: { type: integer }
            details:
              type: array
              items:
                type: object
                properties:
                  field: { type: string }
                  description: { type: string }

    Project:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        platform: { type: string, example: ios }
        createdAt: { type: string, format: date-time }

    CreateProject:
      type: object
      required: [name, platform]
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        platform: { type: string, minLength: 1, maxLength: 50 }

    UpdateProject:
      type: object
      minProperties: 1
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        platform: { type: string, minLength: 1, maxLength: 50 }

    ApiKey:
      type: object
      properties:
        id: { type: string, format: uuid }
        type: { type: string, enum: [ingest, management] }
        prefix:
          type: string
          description: First 12 characters of the key, for display.
          example: rs_ing_9fA2
        lastUsedAt: { type: [string, "null"], format: date-time }
        revokedAt: { type: [string, "null"], format: date-time }
        createdAt: { type: string, format: date-time }

    CreateApiKey:
      type: object
      required: [type]
      properties:
        type: { type: string, enum: [ingest, management] }

    LinearConfig:
      type: object
      required: [apiKey, teamId]
      properties:
        apiKey: { type: string }
        teamId: { type: string }
        labelName: { type: string }

    WebhookConfig:
      type: object
      required: [url, secret]
      properties:
        url: { type: string, format: uri }
        secret: { type: string }

    CreateDestination:
      oneOf:
        - type: object
          required: [type, config]
          properties:
            type: { type: string, enum: [linear] }
            config: { $ref: "#/components/schemas/LinearConfig" }
        - type: object
          required: [type, config]
          properties:
            type: { type: string, enum: [webhook] }
            config: { $ref: "#/components/schemas/WebhookConfig" }

    UpdateDestination:
      type: object
      minProperties: 1
      properties:
        enabled: { type: boolean }
        config:
          description: New config, validated against the destination's type.
          oneOf:
            - { $ref: "#/components/schemas/LinearConfig" }
            - { $ref: "#/components/schemas/WebhookConfig" }

    Destination:
      type: object
      description: Secrets in `config` are redacted to a short prefix.
      properties:
        id: { type: string, format: uuid }
        type: { type: string, enum: [linear, webhook] }
        enabled: { type: boolean }
        config:
          type: object
          description: Redacted config; apiKey/secret show a prefix only.
        createdAt: { type: string, format: date-time }

    DeliveryResult:
      type: object
      properties:
        outcome:
          type: string
          enum: [success, retryable, permanent]
          description: >
            success = delivered; retryable = transient failure (timeout, 5xx,
            429, network) that will be retried; permanent = will never succeed
            (bad credentials, invalid target, SSRF-blocked URL).
        detail: { type: string }

    Report:
      type: object
      properties:
        id: { type: string, format: uuid }
        projectId: { type: string, format: uuid }
        message: { type: string }
        screenshotUrl: { type: [string, "null"] }
        metadata: { $ref: "#/components/schemas/ReportMetadata" }
        status: { type: string, enum: [received, delivered, failed] }
        createdAt: { type: string, format: date-time }

    DeliverySummary:
      type: object
      description: Compact per-delivery status attached to each listed report.
      properties:
        destinationId: { type: string, format: uuid }
        type: { type: string, enum: [linear, webhook] }
        status: { type: string, enum: [pending, success, failed] }
        attempts: { type: integer }

    ReportSummary:
      allOf:
        - $ref: "#/components/schemas/Report"
        - type: object
          properties:
            deliveries:
              type: array
              items: { $ref: "#/components/schemas/DeliverySummary" }

    DeliveryDetail:
      allOf:
        - $ref: "#/components/schemas/DeliverySummary"
        - type: object
          properties:
            id: { type: string, format: uuid }
            lastError: { type: [string, "null"] }
            deliveredAt: { type: [string, "null"], format: date-time }
            lastAttemptAt: { type: [string, "null"], format: date-time }
            createdAt: { type: string, format: date-time }

    ReportPayload:
      type: object
      required: [message, metadata]
      properties:
        message: { type: string, minLength: 1, maxLength: 10000 }
        metadata: { $ref: "#/components/schemas/ReportMetadata" }

    ReportMetadata:
      type: object
      required: [deviceModel, osVersion, appVersion, buildNumber, locale]
      properties:
        deviceModel: { type: string }
        osVersion: { type: string }
        appVersion: { type: string }
        buildNumber: { type: string }
        locale: { type: string }
        lowPowerMode: { type: boolean }

    WebhookDeliveryPayload:
      type: object
      description: >
        Body POSTed to webhook destinations. The request carries three headers:
        `X-Rageshake-Timestamp` (unix seconds), `X-Rageshake-Delivery` (the
        delivery id), and `X-Rageshake-Signature` = `sha256=<hex>` where <hex> is
        HMAC-SHA256 over the string `${timestamp}.${rawBody}` keyed by the
        destination secret (Stripe-style). Reject requests whose recomputed
        signature does not match, or whose timestamp is too old, using a
        constant-time comparison. Node verification example:


        ```js

        import { createHmac, timingSafeEqual } from "node:crypto";


        function verify(rawBody, headers, secret) {
          const ts = headers["x-rageshake-timestamp"];
          const expected =
            "sha256=" +
            createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
          const got = headers["x-rageshake-signature"] ?? "";
          const a = Buffer.from(got);
          const b = Buffer.from(expected);
          return a.length === b.length && timingSafeEqual(a, b);
        }

        ```
      properties:
        report:
          type: object
          properties:
            id: { type: string, format: uuid }
            projectId: { type: string, format: uuid }
            message: { type: string }
            screenshotUrl: { type: [string, "null"] }
            metadata: { $ref: "#/components/schemas/ReportMetadata" }
            createdAt: { type: string, format: date-time }
        project:
          type: object
          properties:
            id: { type: string, format: uuid }
        deliveredAt: { type: string, format: date-time }
