openapi: 3.0.3
info:
  title: VoidPaste API
  version: 1.0.0
  description: |
    REST API for VoidPaste — fast, private paste sharing for developers.

    **Production base URL:** `https://voidpaste.com` (same origin for web and API;
    routes under `/api/v1/...`, plus `/raw/{id}` and `/download/{id}` aliases).

    The product is **free**. Each paste is limited to **1 MB** (1048576 bytes).
    There are no paid tiers. Stripe checkout routes exist only as dormant operator
    hooks and return **501** unless Stripe env is deliberately set.

    **Auth:** session cookie `vp_session` (web) or `Authorization: Bearer vp_live_...`
    (API keys). Password pastes use header `X-Paste-Password` only (not the query string).

    Transactional mail (verify / password reset) is sent from **hello@voidpaste.com**
    in production (`MAIL_FROM=VoidPaste <hello@voidpaste.com>`).
servers:
  - url: https://voidpaste.com
    description: Production
tags:
  - name: pastes
  - name: auth
  - name: api-keys
  - name: collections
  - name: moderation
  - name: admin
    description: Session-only operator routes. Requires role admin or owner.
  - name: billing
    description: Dormant — not offered in the product UI
paths:
  /health:
    get:
      summary: Liveness
      responses:
        "200":
          description: '{"status":"ok"}'
  /ready:
    get:
      summary: Readiness (Postgres; Redis when configured)
      responses:
        "200":
          description: Ready
        "503":
          description: Not ready
  /metrics:
    get:
      summary: Simple in-process request counters (JSON)
      description: |
        JSON only. Not Prometheus text exposition and not OpenTelemetry.
        Structured JSON access logs with `request_id` are the primary signal.
      responses:
        "200":
          description: Counters
  /raw/{id}:
    get:
      tags: [pastes]
      summary: Raw paste body (alias)
      parameters:
        - $ref: "#/components/parameters/PasteID"
        - $ref: "#/components/parameters/PastePassword"
      responses:
        "200":
          description: text/plain; nosniff; CSP sandbox; DENY framing
          content:
            text/plain:
              schema:
                type: string
        "401":
          $ref: "#/components/responses/Error"
        "404":
          $ref: "#/components/responses/Error"
        "410":
          description: Expired or burned
  /download/{id}:
    get:
      tags: [pastes]
      summary: Download paste as attachment (alias)
      parameters:
        - $ref: "#/components/parameters/PasteID"
        - $ref: "#/components/parameters/PastePassword"
      responses:
        "200":
          description: text/plain attachment
          content:
            text/plain:
              schema:
                type: string
        "401":
          $ref: "#/components/responses/Error"
        "404":
          $ref: "#/components/responses/Error"
  /api/v1/pastes:
    post:
      tags: [pastes]
      summary: Create paste (anonymous allowed)
      description: |
        Default visibility is **unlisted** when omitted.
        `password` is only valid with `visibility=password` (otherwise 400).
        Secret detection may return `warnings` or block with 422.
        Optional `Idempotency-Key` (max 256 chars) stores/replays the JSON
        response for 24h per user (or anon IP). Concurrent duplicate keys
        return 409. Replay sets `Idempotency-Replayed: true`.
        API keys require `pastes:write` (or `*`).
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            maxLength: 256
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PasteCreate"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PasteCreateEnvelope"
        "400":
          $ref: "#/components/responses/Error"
        "403":
          description: insufficient_scope (API key missing pastes:write)
        "409":
          description: idempotency_conflict
        "413":
          description: Paste exceeds 1 MB
        "422":
          description: Secret detection or malware scanner blocked the body (not stored)
        "429":
          $ref: "#/components/responses/Error"
        "503":
          description: idempotency_unavailable
  /api/v1/pastes/{id}:
    get:
      tags: [pastes]
      summary: Get paste metadata + content when authorized
      parameters:
        - $ref: "#/components/parameters/PasteID"
        - $ref: "#/components/parameters/PastePassword"
      responses:
        "200":
          description: Paste envelope
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PasteGetEnvelope"
        "401":
          description: password_required / bad_password
        "403":
          $ref: "#/components/responses/Error"
        "404":
          $ref: "#/components/responses/Error"
        "410":
          description: expired / burned
    patch:
      tags: [pastes]
      summary: Update paste (owner)
      security:
        - session: []
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/PasteID"
      responses:
        "200":
          description: Updated
        "401":
          $ref: "#/components/responses/Error"
        "403":
          $ref: "#/components/responses/Error"
    delete:
      tags: [pastes]
      summary: Soft-delete paste (owner)
      security:
        - session: []
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/PasteID"
      responses:
        "204":
          description: Deleted
  /api/v1/pastes/{id}/meta:
    get:
      tags: [pastes]
      summary: Paste metadata without content, view increment, or burn
      description: |
        For HTML/OG tags. Does not increment view_count, does not burn, and
        omits the body. Opening the paste still uses GET /api/v1/pastes/{id}.
      parameters:
        - $ref: "#/components/parameters/PasteID"
        - $ref: "#/components/parameters/PastePassword"
      responses:
        "200":
          description: Metadata envelope (no content field)
        "401":
          description: password_required / bad_password
        "403":
          $ref: "#/components/responses/Error"
        "404":
          $ref: "#/components/responses/Error"
        "410":
          description: expired / burned
  /api/v1/pastes/{id}/versions:
    get:
      tags: [pastes]
      summary: List paste versions (owner)
      security:
        - session: []
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/PasteID"
      responses:
        "200":
          description: Version list
        "403":
          description: Unavailable
  /api/v1/pastes/{id}/versions/{version}:
    get:
      tags: [pastes]
      summary: Get a paste version body (owner)
      security:
        - session: []
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/PasteID"
        - name: version
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Version with content
  /api/v1/pastes/{id}/versions/{version}/restore:
    post:
      tags: [pastes]
      summary: Restore a historical version (owner)
      security:
        - session: []
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/PasteID"
        - name: version
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Restored paste metadata
  /api/v1/pastes/{id}/diff:
    get:
      tags: [pastes]
      summary: Diff two versions (to=0 means current)
      security:
        - session: []
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/PasteID"
        - name: from
          in: query
          required: true
          schema:
            type: integer
        - name: to
          in: query
          schema:
            type: integer
            default: 0
      responses:
        "200":
          description: '{"diff":"..."}'
  /api/v1/me/pastes:
    get:
      tags: [pastes]
      summary: List my pastes
      security:
        - session: []
        - apiKey: []
      responses:
        "200":
          description: List
  /api/v1/auth/register:
    post:
      tags: [auth]
      summary: Register
      description: |
        Sends a verification email (production From hello@voidpaste.com).
        Link points at the web app `/verify-email?token=...` which POSTs the
        token to this API.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:
                  type: string
                  format: email
                password:
                  type: string
                display_name:
                  type: string
      responses:
        "201":
          description: '{"user":{...}}' (dev may also include verify_token)
        "409":
          description: Email already registered
  /api/v1/auth/login:
    post:
      tags: [auth]
      summary: Login (sets vp_session cookie)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:
                  type: string
                password:
                  type: string
      responses:
        "200":
          description: Logged in
  /api/v1/auth/logout:
    post:
      tags: [auth]
      summary: Logout current session
      responses:
        "200":
          description: Logged out
  /api/v1/auth/me:
    get:
      tags: [auth]
      summary: Current user
      security:
        - session: []
        - apiKey: []
      responses:
        "200":
          description: User
  /api/v1/auth/csrf:
    get:
      tags: [auth]
      summary: Issue CSRF double-submit token
      description: |
        Sets a readable `vp_csrf` cookie and returns `{csrf_token}`.
        Cookie-authenticated mutating requests must send header `X-CSRF-Token`
        matching the cookie. Bearer API keys skip CSRF.
      responses:
        "200":
          description: CSRF token
  /api/v1/auth/providers:
    get:
      tags: [auth]
      summary: List enabled auth providers (no secrets)
      description: password is always true; github when OAuth env is set; webauthn when configured at boot.
      responses:
        "200":
          description: Providers map (password, github, webauthn booleans)
  /api/v1/auth/oauth/github:
    get:
      tags: [auth]
      summary: Start GitHub OAuth (302 when configured)
      responses:
        "302":
          description: Redirect to GitHub
        "404":
          description: OAuth not configured
  /api/v1/auth/oauth/github/callback:
    get:
      tags: [auth]
      summary: GitHub OAuth callback
      responses:
        "302":
          description: Redirect to app dashboard or login error
  /api/v1/auth/webauthn/register/begin:
    post:
      tags: [auth]
      summary: WebAuthn registration begin
      security:
        - session: []
      responses:
        "200":
          description: Creation options + session id
        "501":
          description: WebAuthn unavailable
  /api/v1/auth/webauthn/register/finish:
    post:
      tags: [auth]
      summary: WebAuthn registration finish
      security:
        - session: []
      responses:
        "200":
          description: Credential stored
        "501":
          description: WebAuthn unavailable
  /api/v1/auth/webauthn/login/begin:
    post:
      tags: [auth]
      summary: WebAuthn login begin
      responses:
        "200":
          description: Assertion options + session id
        "501":
          description: WebAuthn unavailable
  /api/v1/auth/webauthn/login/finish:
    post:
      tags: [auth]
      summary: WebAuthn login finish (sets session cookie)
      responses:
        "200":
          description: Authenticated
        "501":
          description: WebAuthn unavailable
  /api/v1/auth/webauthn/credentials:
    get:
      tags: [auth]
      summary: List registered passkeys
      security:
        - session: []
      responses:
        "200":
          description: Credentials
  /api/v1/auth/webauthn/credentials/{id}:
    delete:
      tags: [auth]
      summary: Remove a passkey (id is base64 credential id)
      security:
        - session: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Deleted
  /api/v1/billing/status:
    get:
      tags: [billing]
      summary: Billing status (dormant; product is free)
      responses:
        "200":
          description: Includes stripe_checkout boolean. No public prices.
  /api/v1/billing/checkout:
    post:
      tags: [billing]
      summary: Stripe Checkout (dormant; 501 unless operator env is set)
      security:
        - session: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan]
              properties:
                plan:
                  type: string
                  enum: [pro, team]
      responses:
        "200":
          description: Checkout URL (only when Stripe configured)
        "501":
          description: Stripe not configured (normal for free product)
  /api/v1/billing/stripe/webhook:
    post:
      tags: [billing]
      summary: Stripe webhook (dormant)
      responses:
        "501":
          description: Billing not configured
        "400":
          description: Invalid signature
        "200":
          description: Event accepted
  /api/v1/mail/resend/webhook:
    post:
      tags: [mail]
      summary: Resend (Svix) delivery webhook
      description: |
        Stores last_event for Resend message ids when RESEND_WEBHOOK_SECRET is set.
        Returns 501 until configured. Does not prove inbox delivery by itself —
        configure the webhook in the Resend dashboard and subscribe to email.delivered.
      responses:
        "501":
          description: RESEND_WEBHOOK_SECRET unset
        "400":
          description: Invalid signature or payload
        "200":
          description: Event accepted or ignored
  /api/v1/mail/resend/events/{id}:
    get:
      tags: [mail]
      summary: Stored Resend last_event for a message id
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Stored event (accepted/sent/delivered/…)
        "404":
          description: No event stored
  /api/v1/me/usage:
    get:
      tags: [auth]
      summary: Account usage aggregates (30d)
      security:
        - session: []
        - apiKey: []
      responses:
        "200":
          description: Usage
  /api/v1/collections:
    get:
      tags: [collections]
      summary: List my collections
      security:
        - session: []
        - apiKey: []
      responses:
        "200":
          description: Collections
    post:
      tags: [collections]
      summary: Create collection
      security:
        - session: []
        - apiKey: []
      responses:
        "201":
          description: Created
        "403":
          description: Collections unavailable
  /api/v1/api-keys:
    get:
      tags: [api-keys]
      summary: List API keys
      security:
        - session: []
      responses:
        "200":
          description: Keys (secrets never returned)
    post:
      tags: [api-keys]
      summary: Create API key
      description: |
        Secret returned once (`vp_live_...`). Optional `scopes` array
        (defaults `pastes:read`, `pastes:write`) is enforced on API-key
        requests: `pastes:read` for reads, `pastes:write` for creates/updates/
        deletes. `*` grants all. API key management requires a session cookie
        (Bearer API keys are rejected on these routes).
      security:
        - session: []
      responses:
        "201":
          description: Key created (secret shown once)
  /api/v1/me/favorites:
    get:
      tags: [pastes]
      summary: List favorites
      security:
        - session: []
        - apiKey: []
      responses:
        "200":
          description: Favorites
  /api/v1/moderation/reports:
    get:
      tags: [moderation]
      summary: List abuse reports (admin+)
      security:
        - session: []
      responses:
        "200":
          description: Reports
  /api/v1/moderation/reports/{id}/resolve:
    post:
      tags: [moderation]
      summary: Resolve or dismiss a report
      security:
        - session: []
      responses:
        "200":
          description: Updated
  /api/v1/pastes/{id}/quarantine:
    post:
      tags: [moderation]
      summary: Quarantine a paste (admin+)
      security:
        - session: []
      parameters:
        - $ref: "#/components/parameters/PasteID"
      responses:
        "200":
          description: Quarantined
  /api/v1/pastes/{id}/unquarantine:
    post:
      tags: [moderation]
      summary: Lift quarantine (admin+)
      security:
        - session: []
      parameters:
        - $ref: "#/components/parameters/PasteID"
      responses:
        "200":
          description: Restored
  /api/v1/admin/overview:
    get:
      tags: [admin]
      summary: Live counts (users, pastes, open reports, recent signups)
      security:
        - session: []
      responses:
        "200":
          description: Counts from the database
        "403":
          description: Not admin
  /api/v1/admin/users:
    get:
      tags: [admin]
      summary: Search users (no password hashes)
      security:
        - session: []
      responses:
        "200":
          description: User rows
  /api/v1/admin/users/{id}/role:
    post:
      tags: [admin]
      summary: Change a user's role
      description: Cannot change your own role. Only an owner can grant or alter owner. Refuses to remove the last active admin.
      security:
        - session: []
      responses:
        "200":
          description: Updated
        "403":
          description: Forbidden
        "409":
          description: Would remove the last admin
  /api/v1/admin/users/{id}/disable:
    post:
      tags: [admin]
      summary: Disable a user and revoke sessions
      security:
        - session: []
      responses:
        "200":
          description: Disabled
  /api/v1/admin/users/{id}/enable:
    post:
      tags: [admin]
      summary: Re-enable a disabled user
      security:
        - session: []
      responses:
        "200":
          description: Enabled
  /api/v1/admin/pastes:
    get:
      tags: [admin]
      summary: Search paste metadata
      description: Does not return content keys, ciphertext, or password hashes.
      security:
        - session: []
      responses:
        "200":
          description: Paste metadata
  /api/v1/admin/pastes/{id}/delete:
    post:
      tags: [admin]
      summary: Soft-delete a paste
      security:
        - session: []
      parameters:
        - $ref: "#/components/parameters/PasteID"
      responses:
        "200":
          description: Deleted
  /api/v1/admin/rate-limits:
    get:
      tags: [admin]
      summary: Rate-limit configuration and live Redis windows
      description: Denied requests are not persisted. Anonymous subjects are hashed.
      security:
        - session: []
      responses:
        "200":
          description: Limits and current windows
  /api/v1/admin/system:
    get:
      tags: [admin]
      summary: Health checks, mailer backend name, worker config
      description: Mailer name only — never the API key. Worker heartbeat is not recorded.
      security:
        - session: []
      responses:
        "200":
          description: System snapshot
  /api/v1/admin/audit:
    get:
      tags: [admin]
      summary: Audit log (login, paste, and admin actions)
      security:
        - session: []
      responses:
        "200":
          description: Audit entries
  /api/v1/pastes/{id}/raw:
    get:
      tags: [pastes]
      summary: Raw paste body under the API prefix
      parameters:
        - $ref: "#/components/parameters/PasteID"
        - $ref: "#/components/parameters/PastePassword"
      responses:
        "200":
          description: text/plain with nosniff, DENY framing, and sandbox CSP
          content:
            text/plain:
              schema:
                type: string
        "400":
          description: Invalid paste id
        "404":
          $ref: "#/components/responses/Error"
  /api/v1/pastes/{id}/download:
    get:
      tags: [pastes]
      summary: Download paste as attachment under the API prefix
      parameters:
        - $ref: "#/components/parameters/PasteID"
        - $ref: "#/components/parameters/PastePassword"
      responses:
        "200":
          description: text/plain attachment
        "400":
          description: Invalid paste id
        "404":
          $ref: "#/components/responses/Error"
  /api/v1/auth/verify-email:
    post:
      tags: [auth]
      summary: Confirm email verification token
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token:
                  type: string
      responses:
        "200":
          description: Verified
  /api/v1/auth/logout-all:
    post:
      tags: [auth]
      summary: Revoke every session for the current user
      security:
        - session: []
      responses:
        "200":
          description: Logged out
  /api/v1/auth/password-reset/request:
    post:
      tags: [auth]
      summary: Request a password reset email
      description: Always returns success-shaped response (no account enumeration).
      responses:
        "200":
          description: Accepted
  /api/v1/auth/password-reset/confirm:
    post:
      tags: [auth]
      summary: Set a new password with a reset token
      responses:
        "200":
          description: Password updated
  /api/v1/me/favorites/{id}:
    post:
      tags: [pastes]
      summary: Favorite a paste
      security:
        - session: []
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/PasteID"
      responses:
        "201":
          description: Favorited
    delete:
      tags: [pastes]
      summary: Remove a favorite
      security:
        - session: []
        - apiKey: []
      parameters:
        - $ref: "#/components/parameters/PasteID"
      responses:
        "204":
          description: Removed
  /api/v1/api-keys/{id}:
    delete:
      tags: [api-keys]
      summary: Revoke an API key
      security:
        - session: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Revoked
  /api/v1/collections/{id}:
    get:
      tags: [collections]
      summary: Get a collection
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Collection
    patch:
      tags: [collections]
      summary: Update a collection (owner)
      security:
        - session: []
        - apiKey: []
      responses:
        "200":
          description: Updated
    delete:
      tags: [collections]
      summary: Delete a collection (owner)
      security:
        - session: []
        - apiKey: []
      responses:
        "204":
          description: Deleted
  /api/v1/collections/{id}/pastes:
    post:
      tags: [collections]
      summary: Add a paste to a collection
      security:
        - session: []
        - apiKey: []
      responses:
        "201":
          description: Added
  /api/v1/collections/{id}/pastes/{pasteId}:
    delete:
      tags: [collections]
      summary: Remove a paste from a collection
      security:
        - session: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: pasteId
          in: path
          required: true
          description: Public paste id (base62)
          schema:
            type: string
      responses:
        "204":
          description: Removed
        "404":
          description: Collection or paste not in collection
  /api/v1/reports:
    post:
      tags: [moderation]
      summary: Report a paste (abuse)
      responses:
        "201":
          description: Report created
components:
  securitySchemes:
    session:
      type: apiKey
      in: cookie
      name: vp_session
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: vp_live_
  parameters:
    PasteID:
      name: id
      in: path
      required: true
      description: Public paste id (base62), not a database UUID
      schema:
        type: string
    PastePassword:
      name: X-Paste-Password
      in: header
      required: false
      schema:
        type: string
  schemas:
    PasteCreate:
      type: object
      required: [content]
      properties:
        title:
          type: string
        content:
          type: string
          description: UTF-8 body (or ciphertext when client_encrypted)
        language:
          type: string
        visibility:
          type: string
          enum: [public, unlisted, private, password]
          default: unlisted
          description: |
            private requires auth. password requires a non-empty password field.
            Sending password with any other visibility returns 400.
        password:
          type: string
          description: Required when visibility=password
        expiration:
          type: string
          description: |
            Presets: never, 10m, 1h, 1d, 1w, 1M (~30 days, capital M).
            Also accepts relative durations (e.g. 2d, 90m) and RFC3339 timestamps.
            Lowercase 1m is one minute; do not confuse with 1M.
        burn_after_reading:
          type: boolean
        max_views:
          type: integer
          nullable: true
        no_index:
          type: boolean
        client_encrypted:
          type: boolean
        encryption_meta:
          type: object
          additionalProperties: true
    Paste:
      type: object
      properties:
        id:
          type: string
        title:
          type: string
          nullable: true
        language:
          type: string
        visibility:
          type: string
        content:
          type: string
          description: Present when the caller is authorized to read the body
        size_bytes:
          type: integer
        burn_after_reading:
          type: boolean
        max_views:
          type: integer
          nullable: true
        view_count:
          type: integer
        no_index:
          type: boolean
        client_encrypted:
          type: boolean
        encryption_meta:
          type: object
          additionalProperties: true
        content_sha256:
          type: string
        expires_at:
          type: string
          format: date-time
          nullable: true
        burned_at:
          type: string
          format: date-time
          nullable: true
        quarantined_at:
          type: string
          format: date-time
          nullable: true
        owner_id:
          type: string
          format: uuid
        raw_url:
          type: string
          example: /raw/{id}
        download_url:
          type: string
          example: /download/{id}
        api_url:
          type: string
          example: /api/v1/pastes/{id}
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    PasteCreateEnvelope:
      type: object
      properties:
        paste:
          $ref: "#/components/schemas/Paste"
        warnings:
          type: array
          items:
            type: object
            properties:
              severity:
                type: string
              kind:
                type: string
              message:
                type: string
    PasteGetEnvelope:
      type: object
      properties:
        paste:
          $ref: "#/components/schemas/Paste"
        burned:
          type: boolean
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: |
                Examples: unauthorized, forbidden, not_found, validation_error,
                rate_limited, expired, burned, password_required, bad_password,
                secret_blocked, malware_blocked, too_large, limit_exceeded
            message:
              type: string
            request_id:
              type: string
  responses:
    Error:
      description: Error envelope
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
