openapi: "3.1.0"

info:
  title: VeloCMS Public API
  version: "1.0.0"
  description: |
    The VeloCMS REST API gives integrators, Zapier zaps, n8n workflows, and
    custom plugins programmatic access to a tenant's blog content, members,
    media, webhooks, and GDPR tooling.

    **Base URL:** `https://velocms.org`

    ## Authentication

    All `/api/v1/*` endpoints require a Bearer API key issued from your VeloCMS
    dashboard (`/admin/settings → API Keys`).  API keys are plan-gated —
    Pro or higher is required to call any `/api/v1/*` endpoint.

    ```
    Authorization: Bearer vcms_live_xxxxxxxxxxxxxxxx
    ```

    For multi-tenant deployments (subdomain or custom domain routing), the
    middleware injects `x-tenant-id` automatically from the request host.
    When calling from a third-party context, pass the header explicitly:

    ```
    x-tenant-id: <your-tenant-id>
    ```

    ## Scopes

    Each API key is issued with one or more scopes.  Calling an endpoint
    without the matching scope returns `403 INVALID_SCOPE`.

    | Scope | Grants |
    |---|---|
    | `posts:read` | List + view posts |
    | `posts:write` | Create, update, delete posts |
    | `media:read` | List media |
    | `media:write` | Upload media |
    | `comments:read` | List comments |
    | `comments:write` | Submit comments |
    | `comments:moderate` | Approve / reject comments |
    | `members:read` | List members (anonymized) |
    | `members:write` | Update member tier / status |
    | `site-settings:read` | Read public site settings (secrets redacted) |
    | `webhooks:read` | List webhook subscriptions |
    | `webhooks:write` | Create / delete webhook subscriptions |

    ## Rate Limits

    | Plan | Per-minute | Per-hour |
    |---|---|---|
    | Pro | 30 | 1 000 |
    | Business | 120 | 5 000 |
    | Agency | 300 | 20 000 |

    Rate-limited responses return `429` with a `Retry-After` header
    (seconds until the window resets).

    ## Error Envelope

    All errors use a consistent JSON envelope:

    ```json
    {
      "error": {
        "code": "VALIDATION_ERROR",
        "message": "Human-readable description.",
        "details": { "field": ["error detail"] }
      }
    }
    ```

    Error codes: `UNAUTHORIZED`, `INVALID_SCOPE`, `RATE_LIMITED`,
    `QUOTA_EXCEEDED`, `NOT_FOUND`, `VALIDATION_ERROR`, `FORBIDDEN`,
    `INTERNAL_ERROR`, `PLAN_UPGRADE_REQUIRED`, `HTTPS_REQUIRED`.

  contact:
    name: VeloCMS Support
    url: https://velocms.org/help
  license:
    name: MIT
    url: https://github.com/velocms/velocms/blob/main/LICENSE

servers:
  - url: https://velocms.org
    description: Production

tags:
  - name: Health
    description: Uptime and dependency status.
  - name: Posts
    description: Blog post CRUD.  Scope — `posts:read` / `posts:write`.
  - name: Media
    description: Media library.  Scope — `media:read` / `media:write`.
  - name: Comments
    description: Comment threads.  Scope — `comments:read` / `comments:write` / `comments:moderate`.
  - name: Members
    description: Reader / subscriber management.  Scope — `members:read` / `members:write`.
  - name: Site Settings
    description: Tenant-level configuration (secrets redacted).  Scope — `site-settings:read`.
  - name: Webhooks
    description: Outbound webhook subscriptions.  Scope — `webhooks:read` / `webhooks:write`.
  - name: AI
    description: Editor AI content generation (SSE streaming).
  - name: GDPR
    description: GDPR Article 17 (erasure) and Article 20 (data portability).  Session-auth or `x-tenant-id` required.
  - name: Member Webhook
    description: Per-tenant Stripe webhook endpoint (BYOK billing).  Consumed by Stripe — not called by integrators directly.

# ---------------------------------------------------------------------------
# Reusable components
# ---------------------------------------------------------------------------
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: >
        API key issued from /admin/settings.
        Format: `Authorization: Bearer vcms_live_xxxxxxxxxxxxxxxx`

  parameters:
    PageParam:
      name: page
      in: query
      schema:
        type: integer
        minimum: 1
        default: 1
      description: Page number (1-based).
    PerPageParam:
      name: per_page
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
      description: Records per page (max 100).
    PostIdParam:
      name: id
      in: path
      required: true
      schema:
        type: string
      description: PocketBase record ID of the post.
    MediaIdParam:
      name: id
      in: path
      required: true
      schema:
        type: string
      description: PocketBase record ID of the media item.
    CommentIdParam:
      name: id
      in: path
      required: true
      schema:
        type: string
      description: PocketBase record ID of the comment.
    MemberIdParam:
      name: id
      in: path
      required: true
      schema:
        type: string
      description: PocketBase record ID of the member.
    WebhookIdParam:
      name: id
      in: path
      required: true
      schema:
        type: string
      description: PocketBase record ID of the webhook subscription.
    TenantSlugParam:
      name: tenantSlug
      in: path
      required: true
      schema:
        type: string
        pattern: "^[a-z0-9-]{1,63}$"
      description: Tenant's subdomain slug (e.g. `myblog` from `myblog.velocms.org`).
    GdprRequestIdParam:
      name: id
      in: path
      required: true
      schema:
        type: string
      description: GDPR request record ID returned by the initial POST.

  schemas:
    # --- Pagination envelope ---
    PaginatedMeta:
      type: object
      required: [page, per_page, total, total_pages]
      properties:
        page:
          type: integer
        per_page:
          type: integer
        total:
          type: integer
        total_pages:
          type: integer

    # --- Error ---
    ApiError:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - UNAUTHORIZED
                - INVALID_SCOPE
                - RATE_LIMITED
                - QUOTA_EXCEEDED
                - NOT_FOUND
                - VALIDATION_ERROR
                - FORBIDDEN
                - INTERNAL_ERROR
                - PLAN_UPGRADE_REQUIRED
                - HTTPS_REQUIRED
            message:
              type: string
            details:
              type: object
              additionalProperties: true

    # --- Post ---
    PostSummary:
      type: object
      required: [id, title, slug, status, created, updated]
      properties:
        id:
          type: string
        title:
          type: string
        slug:
          type: string
        status:
          type: string
          enum: [draft, published]
        excerpt:
          type: string
          nullable: true
        tags:
          type: array
          items:
            type: string
        published_at:
          type: string
          format: date-time
          nullable: true
        created:
          type: string
          format: date-time
        updated:
          type: string
          format: date-time

    PostFull:
      allOf:
        - $ref: "#/components/schemas/PostSummary"
        - type: object
          properties:
            content_html:
              type: string
            content_json:
              nullable: true
            seo_title:
              type: string
              nullable: true
            seo_description:
              type: string
              nullable: true

    CreatePostBody:
      type: object
      required: [title]
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
        slug:
          type: string
          minLength: 1
          maxLength: 255
          description: Auto-generated from title if omitted.
        content_html:
          type: string
        content_json:
          description: TipTap ProseMirror JSON document (arbitrary structure).
        excerpt:
          type: string
          maxLength: 500
        status:
          type: string
          enum: [draft, published]
          default: draft
        tags:
          type: array
          items:
            type: string
        seo_title:
          type: string
          maxLength: 60
        seo_description:
          type: string
          maxLength: 160

    PatchPostBody:
      type: object
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
        slug:
          type: string
          minLength: 1
          maxLength: 255
        content_html:
          type: string
        content_json:
          description: TipTap ProseMirror JSON document.
        excerpt:
          type: string
          maxLength: 500
        status:
          type: string
          enum: [draft, published]
        tags:
          type: array
          items:
            type: string
        seo_title:
          type: string
          maxLength: 60
        seo_description:
          type: string
          maxLength: 160

    # --- Media ---
    MediaItem:
      type: object
      required: [id, filename, mime_type, size, created]
      properties:
        id:
          type: string
        filename:
          type: string
        url:
          type: string
          format: uri
        mime_type:
          type: string
        size:
          type: integer
          description: File size in bytes.
        width:
          type: integer
          nullable: true
        height:
          type: integer
          nullable: true
        alt:
          type: string
          nullable: true
        created:
          type: string
          format: date-time

    # --- Comment ---
    Comment:
      type: object
      required: [id, post_id, author_name, body, status, created]
      properties:
        id:
          type: string
        post_id:
          type: string
        author_name:
          type: string
        author_email:
          type: string
          nullable: true
        body:
          type: string
        parent_id:
          type: string
          nullable: true
        status:
          type: string
          enum: [approved, pending, spam]
        created:
          type: string
          format: date-time
        updated:
          type: string
          format: date-time

    CreateCommentBody:
      type: object
      required: [post_id, author_name, body]
      properties:
        post_id:
          type: string
        author_name:
          type: string
          minLength: 1
          maxLength: 100
        author_email:
          type: string
          format: email
        body:
          type: string
          minLength: 1
          maxLength: 5000
        parent_id:
          type: string
          description: ID of the parent comment for threaded replies.

    # --- Member ---
    MemberSummary:
      type: object
      required: [id, email, tier, status, created, updated]
      properties:
        id:
          type: string
        email:
          type: string
          description: >
            Email domain is preserved but local part is masked
            (e.g. `u***@example.com`).
        tier:
          type: string
          enum: [free, paid]
        status:
          type: string
          enum: [active, cancelled, past_due]
        created:
          type: string
          format: date-time
        updated:
          type: string
          format: date-time

    # --- Webhook ---
    WebhookSubscription:
      type: object
      required: [id, name, url, events, failure_count, created]
      properties:
        id:
          type: string
        name:
          type: string
        url:
          type: string
          format: uri
        events:
          type: array
          items:
            type: string
            enum:
              - post.published
              - post.updated
              - post.deleted
              - comment.received
              - comment.approved
              - subscriber.added
              - subscriber.removed
              - test.ping
        failure_count:
          type: integer
        last_delivery_at:
          type: string
          format: date-time
          nullable: true
        disabled_at:
          type: string
          format: date-time
          nullable: true
        created:
          type: string
          format: date-time

    CreateWebhookBody:
      type: object
      required: [name, url, events]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
        url:
          type: string
          format: uri
          description: Must use HTTPS.
        events:
          type: array
          minItems: 1
          items:
            type: string
            enum:
              - post.published
              - post.updated
              - post.deleted
              - comment.received
              - comment.approved
              - subscriber.added
              - subscriber.removed
              - test.ping

    WebhookCreatedResponse:
      allOf:
        - $ref: "#/components/schemas/WebhookSubscription"
        - type: object
          properties:
            secret:
              type: string
              description: >
                HMAC-SHA256 signing secret.  Returned **only on creation** —
                store it securely; it is never shown again.

    # --- Site Settings ---
    SiteSettings:
      type: object
      description: >
        Tenant-level configuration.  All encrypted fields
        (`member_stripe_*`, `ai_api_key`) are excluded from the response.
      properties:
        id:
          type: string
        tenant_id:
          type: string
        site_name:
          type: string
        site_description:
          type: string
          nullable: true
        site_logo:
          type: string
          nullable: true
        site_favicon:
          type: string
          nullable: true
        members_enabled:
          type: boolean
        comments_enabled:
          type: boolean
        created:
          type: string
          format: date-time
        updated:
          type: string
          format: date-time

    # --- Health ---
    HealthResponse:
      type: object
      required: [status, ts, build]
      properties:
        status:
          type: string
          enum: [ok, degraded]
        ts:
          type: string
          format: date-time
        pb:
          type: string
          enum: [ok, degraded, down, unconfigured]
        build:
          type: string
          description: Short git SHA of the deployed build.
        error:
          type: string
          description: Present only when pb is degraded or down.

    # --- AI generate ---
    AiGenerateBody:
      type: object
      required: [action]
      properties:
        action:
          type: string
          enum: [generate, rewrite, translate, summarize]
        prompt:
          type: string
          description: Required for `action=generate`.
        selection:
          type: string
          description: Required for `action=rewrite`, `translate`, `summarize`.
        language:
          type: string
          description: Target language — required for `action=translate`.

    # --- GDPR ---
    GdprExportBody:
      type: object
      required: [type]
      properties:
        type:
          type: string
          enum: [tenant, member]
        member_id:
          type: string
          description: Required when `type=member`.
        tenant_id:
          type: string
          description: Override tenant.  Falls back to `x-tenant-id` header.
        notify_email:
          type: string
          format: email
          description: Where to send the download link.
        blog_name:
          type: string
          description: Blog name used in the notification email copy.

    GdprDeleteBody:
      type: object
      required: [type, confirmation_phrase]
      properties:
        type:
          type: string
          enum: [tenant, member]
        confirmation_phrase:
          type: string
          enum: ["DELETE MY DATA"]
          description: Literal string `DELETE MY DATA` required to prevent accidental deletion.
        member_id:
          type: string
          description: Required when `type=member`.
        tenant_id:
          type: string
        tenant_slug:
          type: string
          description: Must match the tenant's slug for `type=tenant`.
        notify_email:
          type: string
          format: email
        blog_name:
          type: string

  responses:
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
    Forbidden:
      description: Key lacks required scope, or plan upgrade required.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
    RateLimited:
      description: Rate limit exceeded.
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds until the rate-limit window resets.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
    ValidationError:
      description: Request body failed Zod validation.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
    InternalError:
      description: Unexpected server error.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"

# ---------------------------------------------------------------------------
# Global security (overridden per-operation where not applicable)
# ---------------------------------------------------------------------------
security:
  - BearerAuth: []

# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
paths:

  # -------------------------------------------------------------------------
  # Health
  # -------------------------------------------------------------------------
  /api/health:
    get:
      tags: [Health]
      operationId: getHealth
      summary: Application health check
      description: >
        Returns the application status and PocketBase connectivity.
        Uptime monitors should use this endpoint.  A `200` response with
        `pb: "down"` becomes `503` — treat either as degraded.
      security: []
      responses:
        "200":
          description: Application is healthy.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthResponse"
              example:
                status: ok
                ts: "2026-05-02T10:00:00.000Z"
                pb: ok
                build: a1b2c3d
        "503":
          description: PocketBase unreachable — application degraded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthResponse"

  # -------------------------------------------------------------------------
  # Posts
  # -------------------------------------------------------------------------
  /api/v1/posts:
    get:
      tags: [Posts]
      operationId: listPosts
      summary: List posts
      description: >
        Returns a paginated list of posts for the authenticated tenant.
        Scope: `posts:read`.
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
        - name: status
          in: query
          schema:
            type: string
            enum: [draft, published]
          description: Filter by post status.
      responses:
        "200":
          description: Paginated post list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedMeta"
                  - type: object
                    required: [items]
                    properties:
                      items:
                        type: array
                        items:
                          $ref: "#/components/schemas/PostSummary"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

    post:
      tags: [Posts]
      operationId: createPost
      summary: Create post
      description: >
        Creates a new post for the authenticated tenant.
        Scope: `posts:write`.  If `status=published` is set,
        `published_at` is stamped automatically.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreatePostBody"
            example:
              title: "Hello World"
              status: "draft"
              tags: ["intro", "welcome"]
      responses:
        "201":
          description: Post created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/PostFull"
        "400":
          description: Invalid JSON body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/posts/{id}:
    get:
      tags: [Posts]
      operationId: getPost
      summary: Get post by ID
      description: Scope — `posts:read`.
      parameters:
        - $ref: "#/components/parameters/PostIdParam"
      responses:
        "200":
          description: Post record.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PostFull"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

    patch:
      tags: [Posts]
      operationId: updatePost
      summary: Update post
      description: >
        Partial update.  Only provided fields are changed.
        Transitioning `status` from `draft` → `published` stamps `published_at`.
        Scope — `posts:write`.
      parameters:
        - $ref: "#/components/parameters/PostIdParam"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PatchPostBody"
      responses:
        "200":
          description: Updated post.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/PostFull"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

    delete:
      tags: [Posts]
      operationId: deletePost
      summary: Delete post
      description: Permanently deletes the post.  Scope — `posts:write`.
      parameters:
        - $ref: "#/components/parameters/PostIdParam"
      responses:
        "204":
          description: Post deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  # -------------------------------------------------------------------------
  # Media
  # -------------------------------------------------------------------------
  /api/v1/media:
    get:
      tags: [Media]
      operationId: listMedia
      summary: List media
      description: Scope — `media:read`.
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
        - name: type
          in: query
          schema:
            type: string
          description: Filter by MIME type prefix (e.g. `image`).
      responses:
        "200":
          description: Paginated media list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedMeta"
                  - type: object
                    required: [items]
                    properties:
                      items:
                        type: array
                        items:
                          $ref: "#/components/schemas/MediaItem"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

    post:
      tags: [Media]
      operationId: uploadMedia
      summary: Upload media file
      description: >
        Accepts `multipart/form-data` with a `file` field.
        Scope — `media:write`.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: The media file to upload.
                alt:
                  type: string
                  description: Alt text for images.
      responses:
        "201":
          description: Media uploaded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/MediaItem"
        "400":
          description: Missing or malformed multipart body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/media/{id}:
    delete:
      tags: [Media]
      operationId: deleteMedia
      summary: Delete media item
      description: Scope — `media:write`.
      parameters:
        - $ref: "#/components/parameters/MediaIdParam"
      responses:
        "204":
          description: Media deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  # -------------------------------------------------------------------------
  # Comments
  # -------------------------------------------------------------------------
  /api/v1/comments:
    get:
      tags: [Comments]
      operationId: listComments
      summary: List comments
      description: Scope — `comments:read`.
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
        - name: post_id
          in: query
          schema:
            type: string
          description: Filter to a specific post.
        - name: status
          in: query
          schema:
            type: string
            enum: [approved, pending, spam]
          description: Filter by moderation status.
      responses:
        "200":
          description: Paginated comment list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedMeta"
                  - type: object
                    required: [items]
                    properties:
                      items:
                        type: array
                        items:
                          $ref: "#/components/schemas/Comment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

    post:
      tags: [Comments]
      operationId: createComment
      summary: Submit a comment
      description: >
        API-submitted comments are auto-approved.
        Scope — `comments:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateCommentBody"
      responses:
        "201":
          description: Comment created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Comment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/comments/{id}/moderate:
    patch:
      tags: [Comments]
      operationId: moderateComment
      summary: Moderate a comment
      description: >
        Set comment status to `approved`, `pending`, or `spam`.
        Scope — `comments:moderate`.
      parameters:
        - $ref: "#/components/parameters/CommentIdParam"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [approved, pending, spam]
      responses:
        "200":
          description: Comment status updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/Comment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"

  # -------------------------------------------------------------------------
  # Members
  # -------------------------------------------------------------------------
  /api/v1/members:
    get:
      tags: [Members]
      operationId: listMembers
      summary: List members (anonymized)
      description: >
        Returns a paginated list of reader / subscriber records.
        Email local parts are masked for privacy (e.g. `u***@example.com`).
        Scope — `members:read`.
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
        - name: tier
          in: query
          schema:
            type: string
            enum: [free, paid]
          description: Filter by subscription tier.
      responses:
        "200":
          description: Paginated member list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedMeta"
                  - type: object
                    required: [items]
                    properties:
                      items:
                        type: array
                        items:
                          $ref: "#/components/schemas/MemberSummary"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/members/{id}:
    patch:
      tags: [Members]
      operationId: updateMember
      summary: Update member tier or status
      description: Scope — `members:write`.
      parameters:
        - $ref: "#/components/parameters/MemberIdParam"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                tier:
                  type: string
                  enum: [free, paid]
                status:
                  type: string
                  enum: [active, cancelled, past_due]
      responses:
        "200":
          description: Member updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/MemberSummary"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"

  # -------------------------------------------------------------------------
  # Site Settings
  # -------------------------------------------------------------------------
  /api/v1/site-settings:
    get:
      tags: [Site Settings]
      operationId: getSiteSettings
      summary: Get site settings
      description: >
        Returns the tenant's site configuration.
        All encrypted fields (`member_stripe_*`, `ai_api_key`) are
        excluded from the response.  Scope — `site-settings:read`.
      responses:
        "200":
          description: Site settings.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SiteSettings"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Site settings record not yet created for this tenant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "429":
          $ref: "#/components/responses/RateLimited"

  # -------------------------------------------------------------------------
  # Webhooks
  # -------------------------------------------------------------------------
  /api/v1/webhooks:
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: List webhook subscriptions
      description: >
        Returns all outbound webhook subscriptions for the tenant.
        The `secret` field is never included.
        Scope — `webhooks:read`.  Requires Pro or higher plan.
      responses:
        "200":
          description: Webhook subscription list.
          content:
            application/json:
              schema:
                type: object
                required: [items, total]
                properties:
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookSubscription"
                  total:
                    type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

    post:
      tags: [Webhooks]
      operationId: createWebhook
      summary: Create webhook subscription
      description: >
        Creates a new outbound webhook subscription.
        The `secret` is returned **only in this response** — store it
        securely and use it to verify incoming `X-VeloCMS-Signature`
        headers with HMAC-SHA256.
        Scope — `webhooks:write`.  Requires Pro or higher plan.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateWebhookBody"
            example:
              name: "Zapier post trigger"
              url: "https://hooks.zapier.com/hooks/catch/abc/xyz/"
              events: ["post.published"]
      responses:
        "201":
          description: Webhook created.  `secret` shown once only.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookCreatedResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/webhooks/{id}:
    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      summary: Delete webhook subscription
      description: Scope — `webhooks:write`.
      parameters:
        - $ref: "#/components/parameters/WebhookIdParam"
      responses:
        "204":
          description: Webhook deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/webhooks/{id}/rotate-secret:
    post:
      tags: [Webhooks]
      operationId: rotateWebhookSecret
      summary: Rotate webhook signing secret
      description: >
        Generates a new HMAC-SHA256 signing secret for the webhook.
        The old secret is invalidated immediately.
        The new secret is returned **once only** — update your receiver
        before calling this endpoint.
        Scope — `webhooks:write`.
      parameters:
        - $ref: "#/components/parameters/WebhookIdParam"
      responses:
        "200":
          description: New secret generated.
          content:
            application/json:
              schema:
                type: object
                required: [id, secret]
                properties:
                  id:
                    type: string
                  secret:
                    type: string
                    description: New HMAC-SHA256 signing secret.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  # -------------------------------------------------------------------------
  # AI Generate (SSE)
  # -------------------------------------------------------------------------
  /api/ai/generate:
    post:
      tags: [AI]
      operationId: aiGenerate
      summary: AI content generation (SSE streaming)
      description: |
        Generates or transforms text using the configured AI provider
        (BYOK or platform Gemini key).

        **Response format:** `text/event-stream` (Server-Sent Events).

        Each SSE event is a JSON object on a `data:` line:

        ```
        data: {"text": "partial output chunk"}\n\n
        data: [DONE]\n\n
        ```

        On error:
        ```
        data: {"error": "Rate limit exceeded."}\n\n
        data: [DONE]\n\n
        ```

        On mid-stream failure:
        ```
        data: {"event": "stream_failed", "reason": "timeout", "retryable": true}\n\n
        data: [DONE]\n\n
        ```

        **Rate limits:** 10 requests per minute per IP (platform key path).
        Per-tenant limits apply on top.

        **Actions:**
        - `generate` — generate content from a `prompt`
        - `rewrite` — rewrite selected `selection`
        - `translate` — translate `selection` to `language`
        - `summarize` — summarize `selection` in 2-3 sentences

        No `Authorization` header required — uses the tenant's BYOK AI key
        (configured in `/admin/settings`) or the platform Gemini key as fallback.
        The `x-tenant-id` header is set automatically by middleware on subdomain
        requests; pass it explicitly when calling from a third-party context.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AiGenerateBody"
            examples:
              generate:
                summary: Generate from prompt
                value:
                  action: generate
                  prompt: "Write an introduction to a post about AI in content marketing."
              rewrite:
                summary: Rewrite selection
                value:
                  action: rewrite
                  selection: "AI tools are very useful for writers."
              translate:
                summary: Translate to Spanish
                value:
                  action: translate
                  selection: "Hello world"
                  language: "Spanish"
      responses:
        "200":
          description: SSE stream opened.
          content:
            text/event-stream:
              schema:
                type: string
                description: Newline-delimited SSE events.
        "400":
          description: Missing required field or unknown action.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "429":
          description: IP or tenant rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"

  # -------------------------------------------------------------------------
  # GDPR
  # -------------------------------------------------------------------------
  /api/gdpr/export:
    post:
      tags: [GDPR]
      operationId: gdprRequestExport
      summary: Request a GDPR data export (Article 20)
      description: |
        Queues a data export.  For small tenants (< 28 s), the export is
        built synchronously and a `download_url` is returned with `200`.
        Larger tenants get `202 Accepted` with a `request_id` — the
        download link is emailed when ready.

        **Auth:** active admin session cookie OR `x-tenant-id` header.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GdprExportBody"
      responses:
        "200":
          description: Export ready (synchronous path).
          content:
            application/json:
              schema:
                type: object
                required: [request_id, status]
                properties:
                  request_id:
                    type: string
                  status:
                    type: string
                    enum: [ready]
                  download_url:
                    type: string
                    format: uri
                  expires_at:
                    type: string
                    format: date-time
                  inline_data:
                    description: Present for `type=member` small exports.
        "202":
          description: Export queued (async path) — download link will be emailed.
          content:
            application/json:
              schema:
                type: object
                required: [request_id, status]
                properties:
                  request_id:
                    type: string
                  status:
                    type: string
                    enum: [queued]
        "400":
          description: Missing tenant or member_id.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          $ref: "#/components/responses/ValidationError"

  /api/gdpr/export/{id}:
    get:
      tags: [GDPR]
      operationId: gdprDownloadExport
      summary: Download a completed GDPR export
      description: >
        Returns the export archive for a completed `gdpr_requests` record.
        The URL is signed with the actor ID as a token (`?token=...`).
        Links expire after 7 days.
      security: []
      parameters:
        - $ref: "#/components/parameters/GdprRequestIdParam"
        - name: token
          in: query
          required: true
          schema:
            type: string
          description: Actor ID token from the export ready email.
      responses:
        "200":
          description: Export archive (ZIP or JSON).
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "404":
          $ref: "#/components/responses/NotFound"

  /api/gdpr/delete:
    post:
      tags: [GDPR]
      operationId: gdprRequestDelete
      summary: Request account or tenant deletion (Article 17)
      description: |
        Schedules a hard deletion with a 30-day grace period.
        Immediately:
        - Creates a `gdpr_requests` record (`type=delete`, `status=queued`)
        - For `type=tenant`: sets `read_only_mode=true` and stamps `deleted_at`
        - Sends a confirmation email with a cancellation link

        Hard delete is deferred 30 days.

        **Auth:** active admin session cookie OR `x-tenant-id` header.

        **Warning:** this action cannot be undone after the grace period.
        The `confirmation_phrase` field (`"DELETE MY DATA"`) is required to
        prevent accidental deletion.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GdprDeleteBody"
            example:
              type: tenant
              confirmation_phrase: "DELETE MY DATA"
              tenant_slug: "myblog"
              notify_email: "owner@example.com"
      responses:
        "202":
          description: Deletion scheduled.
          content:
            application/json:
              schema:
                type: object
                required: [request_id, status, grace_until, cancellation_url]
                properties:
                  request_id:
                    type: string
                  status:
                    type: string
                    enum: [queued]
                  type:
                    type: string
                    enum: [tenant, member]
                  grace_until:
                    type: string
                    format: date-time
                    description: Hard delete occurs after this timestamp.
                  cancellation_url:
                    type: string
                    format: uri
                    description: GET this URL within the grace period to cancel.
        "400":
          description: Missing or invalid body field.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          $ref: "#/components/responses/ValidationError"

  /api/gdpr/delete/{id}/cancel:
    get:
      tags: [GDPR]
      operationId: gdprCancelDelete
      summary: Cancel a pending deletion request
      description: >
        Cancels a deletion within the 30-day grace window.
        The cancellation URL is included in the deletion confirmation email.
      security: []
      parameters:
        - $ref: "#/components/parameters/GdprRequestIdParam"
        - name: token
          in: query
          required: true
          schema:
            type: string
          description: Cancellation token from the confirmation email.
      responses:
        "200":
          description: Deletion cancelled.
          content:
            application/json:
              schema:
                type: object
                required: [request_id, status]
                properties:
                  request_id:
                    type: string
                  status:
                    type: string
                    enum: [cancelled]
        "404":
          $ref: "#/components/responses/NotFound"

  # -------------------------------------------------------------------------
  # Member Webhook (Stripe BYOK — consumed by Stripe, not integrators)
  # -------------------------------------------------------------------------
  /api/member-webhook/{tenantSlug}:
    post:
      tags: [Member Webhook]
      operationId: memberStripeWebhook
      summary: Per-tenant Stripe webhook receiver
      description: |
        Receives Stripe event payloads for a specific tenant's BYOK Stripe
        account.  Stripe calls this endpoint directly — integrators do NOT
        call it.

        **Stripe events handled:**
        - `checkout.session.completed` (membership + digital products + cart)
        - `customer.subscription.updated`
        - `customer.subscription.deleted`
        - `invoice.payment_failed`
        - `charge.refunded`

        Authentication is via Stripe's own `stripe-signature` header
        (HMAC-SHA256 signed payload).  The tenant's webhook secret is stored
        AES-256-GCM encrypted in `site_settings.member_stripe_webhook_secret`.

        Configure the endpoint URL in your Stripe Dashboard webhook settings:
        ```
        https://<your-subdomain>.velocms.org/api/member-webhook/<your-slug>
        ```
      security: []
      parameters:
        - $ref: "#/components/parameters/TenantSlugParam"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Raw Stripe event payload (arbitrary structure).
        description: >
          Raw Stripe event JSON body.  Do not modify before forwarding —
          the signature verification requires the exact raw payload bytes.
      responses:
        "200":
          description: Event received and processed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  received:
                    type: boolean
        "400":
          description: Missing signature, invalid tenant slug, or unsupported event.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "404":
          description: Tenant not found or Stripe not configured.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
        "500":
          $ref: "#/components/responses/InternalError"
