openapi: 3.1.0

info:
  title: bAInquet API
  version: 0.4.0
  summary: REST API for the bAInquet AI-native website knowledge network.
  description: |
    **bAInquet** turns any website (or any AI-managed project) into a verified,
    structured, AI-optimized **Knowledge Node** served from a CDN and consumed by
    LLMs, agents, search engines, and MCP clients. A connector pushes content in;
    a worker pipeline turns that content into a knowledge graph; exporter modules
    serialize the graph into static node files that are published behind a CDN.

    This document is the authoritative OpenAPI description of the REST surface as
    it is **actually built** in `apps/api/src/modules/*/router.ts` and the shell
    router in `apps/api/src/app.ts`. It is cross-checked against the frozen
    contracts in `development/shared/` and the canonical error-code catalogue in
    `packages/shared/src/error-codes.ts` (`contractVersion 0.4.0`). Where the
    running code diverges from the prose specs, **the code wins**.

    ## Response envelope

    Every JSON response uses a single wrapper (`development/shared/api_envelope_spec.md`).
    Consumers branch on the boolean `ok` discriminator, never on HTTP status alone
    (a `200` can carry a partial-batch result).

    Success (`Content-Type: application/json`):

    ```json
    { "ok": true, "data": <T>, "meta": { "requestId": "…", "cursor": "…" } }
    ```

    Error (`Content-Type: application/problem+json`, RFC 9457 superset):

    ```json
    { "ok": false, "error": { "code": "website.not_verified", "message": "…",
      "type": "https://docs.bainquet.online/errors/website.not_verified",
      "status": 409, "requestId": "…", "retryable": false, "details": {} } }
    ```

    `204 No Content` responses carry **no body** (no envelope) — used for
    idempotent deletes/acks. `error.code` is the primary programmatic key and is
    always one of the canonical codes enumerated on the `ErrorBody` schema.

    ## Authentication model

    Three principal types reach the public edge (`development/shared/auth_principal_spec.md`):

    - **User** (`userJwt`): human, short-lived `EdDSA`/`RS256` access JWT
      (`Authorization: Bearer <jwt>`) plus a rotating httpOnly refresh cookie
      (`bnq_refresh`). Carries an RBAC role: `viewer < editor <
      organization_admin < organization_owner < admin < super_admin`. Most
      `/v1/...` routes require this scheme; the minimum role per route is noted in
      each operation's description.
    - **Connector** (`connectorToken`): machine credential for ingestion only.
      `Authorization: Bearer <connectorId>.<secret>` PLUS per-request HMAC headers
      (`X-Signature`, `X-Timestamp`, `X-Nonce`, …) and an `Idempotency-Key`. NOT
      in the user RBAC ladder. See the `Ingestion` tag.
    - **ApiKey** (`apiKey`): Business-plan customer programmatic access,
      `Authorization: Bearer bnq_live_…` or `X-Api-Key: bnq_live_…`. Defined for
      completeness; the lifecycle-management routes are seeded but not yet exposed
      as REST operations in the built routers.

    Public routes (registration, login, refresh, password forgot/reset, waitlist,
    and the System probes) require no authentication.

    ## Demo

    A comprehensive demo seed (`apps/api/src/seed/seed-demo.ts`) provisions a
    verified website with sample products. Sign in with:

    - email: `web@woptima.com`
    - password: `Pass.pass1`

    The demo organization is **Woptima Demo** (`woptima-demo`), primary website
    `woptima-demo.bainquet.online`.
  contact:
    name: bAInquet API support
    url: https://docs.bainquet.online
    email: radiobros26@gmail.com
  license:
    name: Proprietary
    identifier: LicenseRef-Proprietary

servers:
  - url: http://localhost:8090
    description: Local host-run dev server (paths carry the full /v1 prefix; shell probes sit at the root).
  - url: http://localhost:8080
    description: Docker Compose dev server.
  - url: https://api.bainquet.online
    description: Production.

tags:
  - name: Auth
    description: Registration, login, token rotation, sessions, password lifecycle. (auth_principal_spec §7)
  - name: Organizations
    description: Organization CRUD, membership management, GDPR erasure. (rest_api_spec G2)
  - name: Websites
    description: Website registration and editorial/language settings. (rest_api_spec G3)
  - name: Verification
    description: Domain-ownership verification challenges and checks. (rest_api_spec G4)
  - name: Connectors
    description: Connector token issuance, listing, rotation, revocation. (organization_admin+)
  - name: API keys
    description: Programmatic API-key lifecycle (`bnq_live_*`), hashed at rest, shown once. (organization_admin+)
  - name: Webhooks
    description: Outbound webhook subscription management per website. (organization_admin+)
  - name: Ingestion
    description: Connector-authenticated content ingestion (bearer + HMAC). (ingestion_api_spec)
  - name: Knowledge
    description: Read access over the per-website knowledge graph (search / facts / related).
  - name: Publishing
    description: Publish, preview, and take down a website's knowledge node. (rest_api_spec G8)
  - name: Billing
    description: Plan entitlements and usage counters. (read = organization member+)
  - name: Admin
    description: Cross-tenant operational support. (admin / super_admin only, audited)
  - name: Analytics
    description: Read-only per-website analytics rollups.
  - name: Waitlist
    description: Public marketing waitlist capture.
  - name: System
    description: Unauthenticated liveness / readiness / metrics / root probes (shell router).

security:
  - userJwt: []

paths:
  # ───────────────────────────── System (shell router, app.ts) ────────────────
  /:
    get:
      tags: [System]
      operationId: getRoot
      summary: API root banner
      description: Returns a static service banner. No auth.
      security: []
      responses:
        "200":
          description: Service banner.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RootResponse" }
              example:
                ok: true
                data: { service: api, message: bAInquet API }
                meta: { requestId: c0ffee00-0000-4000-8000-000000000000 }

  /healthz:
    get:
      tags: [System]
      operationId: getHealthz
      summary: Liveness probe
      description: Process is up and the event loop responds. No datastore I/O. No auth.
      security: []
      responses:
        "200":
          description: Process alive.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthzResponse" }
              example:
                ok: true
                data: { status: ok, service: api }
                meta: { requestId: c0ffee00-0000-4000-8000-000000000001 }

  /readyz:
    get:
      tags: [System]
      operationId: getReadyz
      summary: Readiness probe
      description: Round-trips to Postgres (`SELECT 1`). `200` ready, `503` `service.unavailable` when the DB is down or no pool is wired. No auth.
      security: []
      responses:
        "200":
          description: Dependencies ready.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReadyzResponse" }
              example:
                ok: true
                data: { ready: true, service: api }
                meta: { requestId: c0ffee00-0000-4000-8000-000000000002 }
        "503":
          description: Database not ready.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              example:
                ok: false
                error:
                  code: service.unavailable
                  message: Database not ready.
                  type: https://docs.bainquet.online/errors/service.unavailable
                  status: 503
                  requestId: c0ffee00-0000-4000-8000-000000000003
                  retryable: true
                  details: {}

  /metrics:
    get:
      tags: [System]
      operationId: getMetrics
      summary: Prometheus metrics
      description: Prometheus-format metrics from the in-memory registry. Plain text, **not** the JSON envelope. No auth.
      security: []
      responses:
        "200":
          description: Prometheus exposition format.
          content:
            text/plain:
              schema: { type: string }
              example: |
                # HELP http_requests_total Total HTTP requests
                # TYPE http_requests_total counter
                http_requests_total{route="/v1/auth/login",status="200"} 42

  # ───────────────────────────── Auth ─────────────────────────────────────────
  /v1/auth/register:
    post:
      tags: [Auth]
      operationId: authRegister
      summary: Register a new user
      description: |
        Creates a user (and optionally a first organization) and sends an email
        verification token. Public. Email is normalized (trim + lowercase);
        password must be ≥12 chars and must not equal the email.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RegisterRequest" }
            example:
              email: web@woptima.com
              password: Pass.pass1.longer
              orgName: Woptima Demo
      responses:
        "201":
          description: Created; verification email dispatched.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RegisterResponse" }
              example:
                ok: true
                data: { userId: 7c9e6679-7425-40de-944b-e07fc1f90ae7, emailVerificationSent: true }
                meta: { requestId: c0ffee00-0000-4000-8000-000000000010 }
        "409":
          description: Email already registered.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              example:
                ok: false
                error:
                  code: auth.email_taken
                  message: An account with this email already exists.
                  type: https://docs.bainquet.online/errors/auth.email_taken
                  status: 409
                  requestId: c0ffee00-0000-4000-8000-000000000011
                  retryable: false
                  details: {}
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /v1/auth/verify-email:
    post:
      tags: [Auth]
      operationId: authVerifyEmail
      summary: Verify an email address
      description: Consumes a one-time email-verification token. Public.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VerifyEmailRequest" }
            example: { token: ev_8f1c2d3e4a5b6c7d8e9f0a1b2c3d4e5f }
      responses:
        "200":
          description: Email verified.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VerifyEmailResponse" }
              example:
                ok: true
                data: { verified: true }
                meta: { requestId: c0ffee00-0000-4000-8000-000000000012 }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/login:
    post:
      tags: [Auth]
      operationId: authLogin
      summary: Log in
      description: |
        Exchanges credentials for an access JWT plus a rotating refresh token. The
        refresh secret is ALSO set as the httpOnly `bnq_refresh` cookie (scoped to
        `/v1/auth`). Public. Use the demo credentials `web@woptima.com` /
        `Pass.pass1`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LoginRequest" }
            example:
              email: web@woptima.com
              password: Pass.pass1
      responses:
        "200":
          description: "Authenticated. Refresh secret also set as `Set-Cookie: bnq_refresh`."
          headers:
            Set-Cookie:
              description: httpOnly rotating refresh secret (`bnq_refresh`).
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenPairResponse" }
              example:
                ok: true
                data:
                  accessToken: eyJhbGciOiJFZERTQSIsImtpZCI6IjAxSiJ9.eyJzdWIiOiJkMWYifQ.sig
                  tokenType: Bearer
                  expiresIn: 900
                  refreshToken: r_3f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c
                  principal:
                    kind: user
                    id: d1f0a2b3-c4d5-46e7-88f9-0a1b2c3d4e5f
                    organizationId: a3b4c5d6-e7f8-49a0-b1c2-d3e4f5a6b7c8
                    role: organization_owner
                    scopes: []
                    mfaSatisfied: true
                meta: { requestId: c0ffee00-0000-4000-8000-000000000013 }
        "401":
          description: Invalid credentials, MFA required, or email unverified.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              example:
                ok: false
                error:
                  code: auth.invalid_credentials
                  message: Email or password is incorrect.
                  type: https://docs.bainquet.online/errors/auth.invalid_credentials
                  status: 401
                  requestId: c0ffee00-0000-4000-8000-000000000014
                  retryable: false
                  details: {}
        "403":
          description: Email not verified.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              example:
                ok: false
                error:
                  code: auth.email_unverified
                  message: Verify your email before signing in.
                  type: https://docs.bainquet.online/errors/auth.email_unverified
                  status: 403
                  requestId: c0ffee00-0000-4000-8000-000000000015
                  retryable: false
                  details: {}
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/auth/refresh:
    post:
      tags: [Auth]
      operationId: authRefresh
      summary: Rotate tokens
      description: |
        Rotates the refresh token (theft detection: reuse of a rotated token
        revokes the whole family — `auth.refresh_reuse_detected`). The refresh
        secret is read from the `bnq_refresh` cookie OR the request body. Public
        (the refresh secret is the credential).
      security: []
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RefreshRequest" }
            example: { refreshToken: r_3f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c }
      responses:
        "200":
          description: New token pair (refresh rotated).
          headers:
            Set-Cookie:
              description: Rotated httpOnly refresh secret (`bnq_refresh`).
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenPairResponse" }
              example:
                ok: true
                data:
                  accessToken: eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJkMWYifQ.sig2
                  tokenType: Bearer
                  expiresIn: 900
                  refreshToken: r_9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b
                  principal:
                    kind: user
                    id: d1f0a2b3-c4d5-46e7-88f9-0a1b2c3d4e5f
                    organizationId: a3b4c5d6-e7f8-49a0-b1c2-d3e4f5a6b7c8
                    role: organization_owner
                    scopes: []
                    mfaSatisfied: true
                meta: { requestId: c0ffee00-0000-4000-8000-000000000016 }
        "401":
          description: Refresh invalid, expired, or reuse detected (family revoked).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              example:
                ok: false
                error:
                  code: auth.refresh_reuse_detected
                  message: Refresh token reuse detected; all sessions in this family were revoked.
                  type: https://docs.bainquet.online/errors/auth.refresh_reuse_detected
                  status: 401
                  requestId: c0ffee00-0000-4000-8000-000000000017
                  retryable: false
                  details: { familyRevoked: true, reauthRequired: true }

  /v1/auth/logout:
    post:
      tags: [Auth]
      operationId: authLogout
      summary: Log out the current session
      description: Revokes the current session and clears the `bnq_refresh` cookie. Requires a user access token (any role).
      responses:
        "204":
          description: Logged out (no body).
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/logout-all:
    post:
      tags: [Auth]
      operationId: authLogoutAll
      summary: Log out all sessions
      description: Revokes every session for the user and clears the `bnq_refresh` cookie. Requires a user access token (any role).
      responses:
        "204":
          description: All sessions revoked (no body).
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/me:
    get:
      tags: [Auth]
      operationId: authMe
      summary: Current principal projection
      description: Returns the authenticated user's profile projection (id, email, active role/org, memberships, MFA flag). Requires a user access token (any role).
      responses:
        "200":
          description: Current user.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MeResponse" }
              example:
                ok: true
                data:
                  id: d1f0a2b3-c4d5-46e7-88f9-0a1b2c3d4e5f
                  email: web@woptima.com
                  role: organization_owner
                  org: a3b4c5d6-e7f8-49a0-b1c2-d3e4f5a6b7c8
                  memberships:
                    - organizationId: a3b4c5d6-e7f8-49a0-b1c2-d3e4f5a6b7c8
                      role: organization_owner
                  mfaEnabled: false
                meta: { requestId: c0ffee00-0000-4000-8000-000000000018 }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/password/change:
    post:
      tags: [Auth]
      operationId: authPasswordChange
      summary: Change password
      description: Changes the password (bumps `tokenVersion`, revoking all sessions). The new password must differ from the current one. Requires a user access token (any role).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PasswordChangeRequest" }
            example: { current: Pass.pass1, next: NewPass.pass1.longer }
      responses:
        "204":
          description: Password changed; sessions revoked (no body).
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /v1/auth/password/forgot:
    post:
      tags: [Auth]
      operationId: authPasswordForgot
      summary: Request a password reset
      description: Always returns `202` (no account enumeration). Public.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ForgotPasswordRequest" }
            example: { email: web@woptima.com }
      responses:
        "202":
          description: Accepted (a reset link is sent if the account exists).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ForgotPasswordResponse" }
              example:
                ok: true
                data: { requested: true }
                meta: { requestId: c0ffee00-0000-4000-8000-000000000019 }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/auth/password/reset:
    post:
      tags: [Auth]
      operationId: authPasswordReset
      summary: Reset password with a token
      description: Sets a new password using a reset token. Public (the token is the credential).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ResetPasswordRequest" }
            example: { token: rst_a1b2c3d4e5f60718293a4b5c6d7e8f90, next: NewPass.pass1.longer }
      responses:
        "204":
          description: Password reset (no body).
        "401":
          description: Reset token invalid or expired.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              example:
                ok: false
                error:
                  code: auth.token_revoked
                  message: Reset token is invalid or expired.
                  type: https://docs.bainquet.online/errors/auth.token_revoked
                  status: 401
                  requestId: c0ffee00-0000-4000-8000-00000000001a
                  retryable: false
                  details: {}
        "422": { $ref: "#/components/responses/ValidationFailed" }

  # ───────────────────────────── Organizations ────────────────────────────────
  /v1/organizations:
    get:
      tags: [Organizations]
      operationId: listOrganizations
      summary: List my organizations
      description: Lists organizations the caller is a member of. Requires a user access token (any role).
      responses:
        "200":
          description: Organizations.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationListResponse" }
              example:
                ok: true
                data:
                  - id: a3b4c5d6-e7f8-49a0-b1c2-d3e4f5a6b7c8
                    name: Woptima Demo
                    slug: woptima-demo
                    ownerUserId: d1f0a2b3-c4d5-46e7-88f9-0a1b2c3d4e5f
                    plan: free
                    billingStatus: active
                    billingRef: null
                    createdAt: 2026-06-10T08:00:00Z
                    updatedAt: 2026-06-10T08:00:00Z
                meta: { requestId: c0ffee00-0000-4000-8000-000000000020 }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Organizations]
      operationId: createOrganization
      summary: Create an organization
      description: Creates an organization; the caller becomes its `organization_owner`. Requires a user access token (any role).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateOrganizationRequest" }
            example: { name: Woptima Demo, slug: woptima-demo }
      responses:
        "201":
          description: Organization created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationResponse" }
              example:
                ok: true
                data:
                  id: a3b4c5d6-e7f8-49a0-b1c2-d3e4f5a6b7c8
                  name: Woptima Demo
                  slug: woptima-demo
                  ownerUserId: d1f0a2b3-c4d5-46e7-88f9-0a1b2c3d4e5f
                  plan: free
                  billingStatus: active
                  billingRef: null
                  createdAt: 2026-06-10T08:00:00Z
                  updatedAt: 2026-06-10T08:00:00Z
                meta: { requestId: c0ffee00-0000-4000-8000-000000000021 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /v1/organizations/{id}:
    parameters:
      - $ref: "#/components/parameters/OrgIdPath"
    get:
      tags: [Organizations]
      operationId: getOrganization
      summary: Read an organization
      description: Reads one organization. Requires membership (any role); admins bypass tenant scoping.
      responses:
        "200":
          description: Organization.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Organizations]
      operationId: updateOrganization
      summary: Update an organization
      description: Partial update of name/slug/billing email. Requires **organization_admin** (write); slug/name owned by owners per the service. Unknown fields are rejected.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateOrganizationRequest" }
            example: { name: Woptima Demo Co, billingEmail: billing@woptima.com }
      responses:
        "200":
          description: Updated organization.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrganizationResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [Organizations]
      operationId: deleteOrganization
      summary: Delete an organization
      description: Deletes an organization. Requires **organization_owner**.
      responses:
        "204":
          description: Deleted (no body).
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/organizations/{id}/members:
    parameters:
      - $ref: "#/components/parameters/OrgIdPath"
    get:
      tags: [Organizations]
      operationId: listOrganizationMembers
      summary: List members
      description: Lists members of the organization. Requires membership (any role).
      responses:
        "200":
          description: Members.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MembershipListResponse" }
              example:
                ok: true
                data:
                  - id: 11111111-2222-4333-8444-555555555555
                    userId: d1f0a2b3-c4d5-46e7-88f9-0a1b2c3d4e5f
                    organizationId: a3b4c5d6-e7f8-49a0-b1c2-d3e4f5a6b7c8
                    role: organization_owner
                    createdAt: 2026-06-10T08:00:00Z
                meta: { requestId: c0ffee00-0000-4000-8000-000000000022 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Organizations]
      operationId: inviteOrganizationMember
      summary: Invite / add a member
      description: Adds a member by email or userId with a role. Requires **organization_admin** (members→owner for owner-level grants). Exactly one of `email`/`userId` is required.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InviteMemberRequest" }
            example: { email: editor@woptima.com, role: editor }
      responses:
        "201":
          description: Member added.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MembershipResponse" }
              example:
                ok: true
                data:
                  id: 22222222-3333-4444-8555-666666666666
                  userId: e2f1b3c4-d5e6-47f8-99a0-b1c2d3e4f5a6
                  organizationId: a3b4c5d6-e7f8-49a0-b1c2-d3e4f5a6b7c8
                  role: editor
                  createdAt: 2026-06-11T09:00:00Z
                meta: { requestId: c0ffee00-0000-4000-8000-000000000023 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /v1/organizations/{id}/members/{userId}:
    parameters:
      - $ref: "#/components/parameters/OrgIdPath"
      - $ref: "#/components/parameters/UserIdPath"
    patch:
      tags: [Organizations]
      operationId: updateOrganizationMemberRole
      summary: Change a member's role
      description: Changes a member's role. Requires **organization_admin** (owner for owner-level changes).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateMemberRoleRequest" }
            example: { role: organization_admin }
      responses:
        "200":
          description: Updated membership.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MembershipResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [Organizations]
      operationId: removeOrganizationMember
      summary: Remove a member
      description: Removes a member from the organization. Requires **organization_admin**.
      responses:
        "204":
          description: Removed (no body).
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/organizations/{id}/erase:
    parameters:
      - $ref: "#/components/parameters/OrgIdPath"
    post:
      tags: [Organizations]
      operationId: eraseOrganization
      summary: GDPR Art.17 hard-erasure
      description: |
        Hard-erases the organization and all tenant data (F-051). Requires
        **organization_owner**. The body `confirm` MUST equal the org slug or name,
        else `validation.failed`. Returns `202` with the erasure request id.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EraseOrganizationRequest" }
            example: { confirm: woptima-demo }
      responses:
        "202":
          description: Erasure accepted (synchronous; status `completed`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EraseOrganizationResponse" }
              example:
                ok: true
                data:
                  erasureRequestId: 33333333-4444-4555-8666-777777777777
                  status: completed
                meta: { requestId: c0ffee00-0000-4000-8000-000000000024 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Caller lacks organization_owner (`privacy.erasure_forbidden`).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              example:
                ok: false
                error:
                  code: privacy.erasure_forbidden
                  message: Only the organization owner may erase the organization.
                  type: https://docs.bainquet.online/errors/privacy.erasure_forbidden
                  status: 403
                  requestId: c0ffee00-0000-4000-8000-000000000025
                  retryable: false
                  details: {}
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  # ───────────────────────────── Websites ─────────────────────────────────────
  /v1/organizations/{orgId}/websites:
    parameters:
      - $ref: "#/components/parameters/OrgIdInOrgPath"
    get:
      tags: [Websites]
      operationId: listWebsites
      summary: List websites in an organization
      description: Lists websites for the org. Requires membership (viewer+).
      responses:
        "200":
          description: Websites.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebsiteListResponse" }
              example:
                ok: true
                data:
                  - id: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2
                    organizationId: a3b4c5d6-e7f8-49a0-b1c2-d3e4f5a6b7c8
                    domain: woptima-demo.bainquet.online
                    normalizedDomain: woptima-demo.bainquet.online
                    status: verified
                    primaryLanguage: en
                    additionalLanguages: [de, fr]
                    reviewMode: "off"
                    reviewImportanceThreshold: 3
                    piiOptin: {}
                    createdAt: 2026-06-10T08:05:00Z
                    updatedAt: 2026-06-11T10:00:00Z
                meta: { requestId: c0ffee00-0000-4000-8000-000000000030 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Websites]
      operationId: createWebsite
      summary: Register a website
      description: Registers a website in the org. Requires **editor** (website write).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateWebsiteRequest" }
            example:
              domain: woptima-demo.bainquet.online
              primaryLanguage: en
              additionalLanguages: [de, fr]
      responses:
        "201":
          description: Website created (initially `pending` verification).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebsiteResponse" }
              example:
                ok: true
                data:
                  id: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2
                  organizationId: a3b4c5d6-e7f8-49a0-b1c2-d3e4f5a6b7c8
                  domain: woptima-demo.bainquet.online
                  normalizedDomain: woptima-demo.bainquet.online
                  status: pending
                  primaryLanguage: en
                  additionalLanguages: [de, fr]
                  reviewMode: "off"
                  reviewImportanceThreshold: 3
                  piiOptin: {}
                  createdAt: 2026-06-10T08:05:00Z
                  updatedAt: 2026-06-10T08:05:00Z
                meta: { requestId: c0ffee00-0000-4000-8000-000000000031 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /v1/websites/{id}:
    parameters:
      - $ref: "#/components/parameters/WebsiteIdPath"
    get:
      tags: [Websites]
      operationId: getWebsite
      summary: Read a website
      description: Reads one website. Requires membership (viewer+).
      responses:
        "200":
          description: Website.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebsiteResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Websites]
      operationId: updateWebsite
      summary: Update website settings
      description: Partial update of editorial/language settings. Requires **editor**. Unknown fields are rejected.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateWebsiteRequest" }
            example:
              reviewMode: required
              reviewImportanceThreshold: 4
              additionalLanguages: [de, fr, es]
      responses:
        "200":
          description: Updated website.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebsiteResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [Websites]
      operationId: deleteWebsite
      summary: Delete a website
      description: Deletes a website. Requires **organization_admin** (website delete).
      responses:
        "204":
          description: Deleted (no body).
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ───────────────────────────── Verification ─────────────────────────────────
  /v1/websites/{id}/verification:
    parameters:
      - $ref: "#/components/parameters/WebsiteIdPath"
    get:
      tags: [Verification]
      operationId: getVerification
      summary: Read verification state
      description: Returns the current verification record for the website. Requires membership (viewer+).
      responses:
        "200":
          description: Verification state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VerificationResponse" }
              example:
                ok: true
                data:
                  websiteId: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2
                  state: verified
                  method: dns_txt
                  tier: standard
                  lastCheckedAt: 2026-06-11T10:00:00Z
                  failureCount: 0
                  verifiedAt: 2026-06-11T10:00:00Z
                  createdAt: 2026-06-10T08:06:00Z
                  updatedAt: 2026-06-11T10:00:00Z
                meta: { requestId: c0ffee00-0000-4000-8000-000000000040 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/websites/{id}/verification/challenge:
    parameters:
      - $ref: "#/components/parameters/WebsiteIdPath"
    post:
      tags: [Verification]
      operationId: createVerificationChallenge
      summary: Issue a verification challenge
      description: Issues a token for the chosen ownership-proof method. Requires **editor**.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VerificationChallengeRequest" }
            example: { method: dns_txt }
      responses:
        "201":
          description: Challenge issued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VerificationChallengeResponse" }
              example:
                ok: true
                data:
                  websiteId: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2
                  state: pending
                  method: dns_txt
                  tier: standard
                  failureCount: 0
                  createdAt: 2026-06-10T08:06:00Z
                  updatedAt: 2026-06-10T08:06:00Z
                  token: bnq-verify=9f2b7c1d4e8a
                  dnsRecordName: _bainquet.woptima-demo.bainquet.online
                  instructions: Add a DNS TXT record _bainquet.woptima-demo.bainquet.online with value bnq-verify=9f2b7c1d4e8a, then run a check.
                meta: { requestId: c0ffee00-0000-4000-8000-000000000041 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /v1/websites/{id}/verification/check:
    parameters:
      - $ref: "#/components/parameters/WebsiteIdPath"
    post:
      tags: [Verification]
      operationId: checkVerification
      summary: Re-run a verification check
      description: |
        Re-runs the stored verification method. Body is optional; for
        `plugin_signed` the connector supplies the signed-challenge fields
        (`nonce`, `ts`, `signature`). Requires **editor**.
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VerificationCheckRequest" }
            example:
              nonce: a1b2c3d4
              ts: 1750000000
              signature: 3f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c
      responses:
        "200":
          description: Check result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VerificationCheckResponse" }
              example:
                ok: true
                data:
                  result: verified
                  verification:
                    websiteId: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2
                    state: verified
                    method: dns_txt
                    tier: standard
                    lastCheckedAt: 2026-06-11T10:00:00Z
                    failureCount: 0
                    verifiedAt: 2026-06-11T10:00:00Z
                    createdAt: 2026-06-10T08:06:00Z
                    updatedAt: 2026-06-11T10:00:00Z
                meta: { requestId: c0ffee00-0000-4000-8000-000000000042 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Verification failed or still in grace.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              example:
                ok: false
                error:
                  code: verification.failed
                  message: Ownership proof could not be confirmed.
                  type: https://docs.bainquet.online/errors/verification.failed
                  status: 409
                  requestId: c0ffee00-0000-4000-8000-000000000043
                  retryable: false
                  details: { verificationState: failed }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  # ───────────────────────────── Connectors ───────────────────────────────────
  /v1/websites/{id}/connectors:
    parameters:
      - $ref: "#/components/parameters/WebsiteIdPath"
    post:
      tags: [Connectors]
      operationId: issueConnector
      summary: Issue a connector token
      description: |
        Issues a connector and returns its bearer token **once** (never retrievable
        again). The token is `connectorId.secret`. Requires **organization_admin**.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateConnectorRequest" }
            example:
              type: wordpress
              allowedSourceTypes: [page, post, product]
              version: 1.0.0
      responses:
        "201":
          description: Connector issued (token shown once).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IssuedConnectorResponse" }
              example:
                ok: true
                data:
                  connectorId: c1d2e3f4-a5b6-47c8-99d0-e1f2a3b4c5d6
                  token: c1d2e3f4-a5b6-47c8-99d0-e1f2a3b4c5d6.s_8f1c2d3e4a5b6c7d8e9f0a1b2c3d4e5f
                  scope:
                    websiteId: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2
                    allowedSourceTypes: [page, post, product]
                    allowedActions: [ingest:write, ingest:delete, ingest:schema, heartbeat:write]
                meta: { requestId: c0ffee00-0000-4000-8000-000000000050 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    get:
      tags: [Connectors]
      operationId: listConnectors
      summary: List a website's connectors
      description: Lists connectors for the website (never returns secrets). Requires **organization_admin** (connector read).
      responses:
        "200":
          description: Connectors.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConnectorListResponse" }
              example:
                ok: true
                data:
                  - id: c1d2e3f4-a5b6-47c8-99d0-e1f2a3b4c5d6
                    websiteId: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2
                    organizationId: a3b4c5d6-e7f8-49a0-b1c2-d3e4f5a6b7c8
                    type: wordpress
                    version: 1.0.0
                    status: active
                    scope:
                      websiteId: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2
                      allowedSourceTypes: [page, post, product]
                      allowedActions: [ingest:write, ingest:delete, ingest:schema, heartbeat:write]
                    tokenVersion: 1
                    lastUsedAt: 2026-06-12T11:00:00Z
                    lastHeartbeatAt: 2026-06-12T11:00:00Z
                    createdAt: 2026-06-10T08:10:00Z
                    updatedAt: 2026-06-12T11:00:00Z
                meta: { requestId: c0ffee00-0000-4000-8000-000000000051 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/connectors/{cid}/rotate:
    parameters:
      - $ref: "#/components/parameters/ConnectorIdPath"
    post:
      tags: [Connectors]
      operationId: rotateConnector
      summary: Rotate a connector token
      description: Re-keys the connector and returns a new bearer token **once**. Requires **organization_admin**. Empty body.
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RotateConnectorRequest" }
            example: {}
      responses:
        "200":
          description: Rotated (new token shown once).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IssuedConnectorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/connectors/{cid}:
    parameters:
      - $ref: "#/components/parameters/ConnectorIdPath"
    delete:
      tags: [Connectors]
      operationId: revokeConnector
      summary: Revoke a connector
      description: Revokes the connector (its token stops authenticating). Requires **organization_admin**.
      responses:
        "204":
          description: Revoked (no body).
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ───────────────────────────── API keys ─────────────────────────────────────
  /v1/organizations/{id}/api-keys:
    parameters:
      - $ref: "#/components/parameters/OrgIdPath"
    post:
      tags: [API keys]
      operationId: createApiKey
      summary: Create an API key
      description: |
        Creates a programmatic API key for the organization. The full key
        (`bnq_live_*`) is returned **once** and never retrievable again; only its
        hash is stored. Requires **organization_admin**.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1, maxLength: 120 }
                scopes:
                  type: array
                  items: { type: string }
            example: { name: "CI publisher", scopes: ["ingest:write"] }
      responses:
        "201":
          description: API key created (full key shown once).
          content:
            application/json:
              example:
                ok: true
                data:
                  id: 9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d
                  name: CI publisher
                  prefix: bnq_live_8f1c
                  key: bnq_live_8f1c2d3e4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d
                  scopes: [ingest:write]
                  createdAt: 2026-06-17T09:00:00Z
                meta: { requestId: c0ffee00-0000-4000-8000-000000000060 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    get:
      tags: [API keys]
      operationId: listApiKeys
      summary: List API keys
      description: Lists the organization's API keys. The secret is never returned (only id, name, prefix, scopes, timestamps). Requires **organization_admin**.
      responses:
        "200":
          description: API keys.
          content:
            application/json:
              example:
                ok: true
                data:
                  - id: 9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d
                    name: CI publisher
                    prefix: bnq_live_8f1c
                    scopes: [ingest:write]
                    lastUsedAt: 2026-06-17T09:30:00Z
                    revokedAt: null
                    createdAt: 2026-06-17T09:00:00Z
                meta: { requestId: c0ffee00-0000-4000-8000-000000000061 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/organizations/{id}/api-keys/{keyId}:
    parameters:
      - $ref: "#/components/parameters/OrgIdPath"
      - name: keyId
        in: path
        required: true
        schema: { type: string, format: uuid }
    delete:
      tags: [API keys]
      operationId: revokeApiKey
      summary: Revoke an API key
      description: Revokes the key (it stops authenticating). Requires **organization_admin**.
      responses:
        "204": { description: Revoked (no body). }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ───────────────────────────── Transfers ────────────────────────────────────
  /v1/organizations/{id}/transfer:
    parameters:
      - $ref: "#/components/parameters/OrgIdPath"
    post:
      tags: [Organizations]
      operationId: initiateOrgTransfer
      summary: Initiate an organization ownership transfer
      description: |
        Starts a pending ownership transfer to the recipient email. The recipient
        accepts via the returned token. Requires **organization_owner**.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [targetEmail]
              properties:
                targetEmail: { type: string, format: email }
            example: { targetEmail: new-owner@example.com }
      responses:
        "201":
          description: Transfer initiated (pending recipient acceptance).
          content:
            application/json:
              example:
                ok: true
                data: { transferId: 1f2e3d4c-5b6a-4978-8c1d-2e3f4a5b6c7d, status: pending, expiresAt: 2026-06-24T09:00:00Z }
                meta: { requestId: c0ffee00-0000-4000-8000-000000000062 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /v1/organizations/{id}/transfer/cancel:
    parameters:
      - $ref: "#/components/parameters/OrgIdPath"
    post:
      tags: [Organizations]
      operationId: cancelOrgTransfer
      summary: Cancel a pending organization transfer
      description: Cancels the organization's pending transfer. Requires **organization_owner**.
      responses:
        "200":
          description: Transfer cancelled.
          content:
            application/json:
              example:
                ok: true
                data: { status: cancelled }
                meta: { requestId: c0ffee00-0000-4000-8000-000000000063 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /v1/organizations/transfers/{token}/accept:
    parameters:
      - name: token
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [Organizations]
      operationId: acceptOrgTransfer
      summary: Accept an organization ownership transfer
      description: |
        The recipient accepts a pending transfer using the token from the
        invitation, becoming the organization owner. Requires an authenticated
        user whose email matches the transfer target.
      responses:
        "200":
          description: Transfer accepted; ownership reassigned.
          content:
            application/json:
              example:
                ok: true
                data: { organizationId: a3b4c5d6-e7f8-49a0-b1c2-d3e4f5a6b7c8, status: accepted }
                meta: { requestId: c0ffee00-0000-4000-8000-000000000064 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /v1/websites/{id}/transfer:
    parameters:
      - $ref: "#/components/parameters/WebsiteIdPath"
    post:
      tags: [Websites]
      operationId: transferWebsite
      summary: Transfer a website to another organization
      description: |
        Moves a website to another organization the caller owns (synchronous).
        Requires **organization_admin** on the source org and
        **organization_owner** on the target org.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [targetOrganizationId]
              properties:
                targetOrganizationId: { type: string, format: uuid }
            example: { targetOrganizationId: a3b4c5d6-e7f8-49a0-b1c2-d3e4f5a6b7c8 }
      responses:
        "200":
          description: Website moved to the target organization.
          content:
            application/json:
              example:
                ok: true
                data: { id: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2, organizationId: a3b4c5d6-e7f8-49a0-b1c2-d3e4f5a6b7c8 }
                meta: { requestId: c0ffee00-0000-4000-8000-000000000065 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  # ───────────────────────────── Webhooks ─────────────────────────────────────
  /v1/websites/{id}/webhooks:
    parameters:
      - $ref: "#/components/parameters/WebsiteIdPath"
    post:
      tags: [Webhooks]
      operationId: createWebhook
      summary: Create a webhook subscription
      description: |
        Subscribes a URL to the website's events. The signing secret is returned
        **once** and never again (only its hash is stored). Requires
        **organization_admin**.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  minItems: 1
                  items: { type: string }
            example:
              url: https://example.com/hooks/bainquet
              events: [node.published]
      responses:
        "201":
          description: Subscription created (signing secret shown once).
          content:
            application/json:
              example:
                ok: true
                data:
                  webhook: { id: 7e6d5c4b-3a29-4187-9c0d-1e2f3a4b5c6d, url: https://example.com/hooks/bainquet, events: [node.published], active: true, createdAt: 2026-06-17T09:00:00Z }
                  secret: whsec_8f1c2d3e4a5b6c7d8e9f0a1b2c3d4e5f
                meta: { requestId: c0ffee00-0000-4000-8000-000000000066 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: List webhook subscriptions
      description: Lists the website's webhook subscriptions (no secret). Requires **organization_admin**.
      responses:
        "200":
          description: Webhook subscriptions.
          content:
            application/json:
              example:
                ok: true
                data:
                  - id: 7e6d5c4b-3a29-4187-9c0d-1e2f3a4b5c6d
                    url: https://example.com/hooks/bainquet
                    events: [node.published]
                    active: true
                    createdAt: 2026-06-17T09:00:00Z
                meta: { requestId: c0ffee00-0000-4000-8000-000000000067 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/websites/{id}/webhooks/{webhookId}:
    parameters:
      - $ref: "#/components/parameters/WebsiteIdPath"
      - name: webhookId
        in: path
        required: true
        schema: { type: string, format: uuid }
    patch:
      tags: [Webhooks]
      operationId: updateWebhook
      summary: Update a webhook subscription
      description: Toggles `active` and/or changes `events` (the signing secret is never changed). Requires **organization_admin**.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                active: { type: boolean }
                events:
                  type: array
                  minItems: 1
                  items: { type: string }
            example: { active: false }
      responses:
        "200":
          description: Subscription updated.
          content:
            application/json:
              example:
                ok: true
                data: { id: 7e6d5c4b-3a29-4187-9c0d-1e2f3a4b5c6d, url: https://example.com/hooks/bainquet, events: [node.published], active: false, createdAt: 2026-06-17T09:00:00Z }
                meta: { requestId: c0ffee00-0000-4000-8000-000000000068 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      summary: Delete a webhook subscription
      description: Removes the subscription. Requires **organization_admin**.
      responses:
        "204": { description: Deleted (no body). }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ───────────────────────────── Ingestion ────────────────────────────────────
  /v1/ingest/batch:
    post:
      tags: [Ingestion]
      operationId: ingestBatch
      summary: Ingest a batch of content items
      description: |
        Submits 1..500 content items. **Connector principal only** (bearer + HMAC;
        see the security headers below). Per-item idempotency on
        `(website_id, item.id, checksum)`; request-level idempotency on the
        required `Idempotency-Key`. Always `ok:true` with per-item results unless
        the whole request is rejected (auth/HMAC/payload/`partial:false`).
      security:
        - connectorToken: []
      parameters:
        - $ref: "#/components/parameters/XSignature"
        - $ref: "#/components/parameters/XTimestamp"
        - $ref: "#/components/parameters/XNonce"
        - $ref: "#/components/parameters/XSiteDomain"
        - $ref: "#/components/parameters/XConnectorType"
        - $ref: "#/components/parameters/XConnectorVersion"
        - $ref: "#/components/parameters/XBodySha256"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IngestBatchRequest" }
            example:
              website_id: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2
              partial: true
              items:
                - type: product
                  id: demo-prod-1
                  url: https://woptima-demo.bainquet.online/products/atlas-desk
                  canonical_url: https://woptima-demo.bainquet.online/products/atlas-desk
                  title: Atlas Standing Desk
                  html: "<h1>Atlas Standing Desk</h1>"
                  text: Atlas Standing Desk. Height-adjustable workspace hardware.
                  json: { price: 599.0, currency: EUR, sku: ATLAS-001 }
                  language: en
                  checksum: sha256:9f2b7c1d4e8a0b3c5d7e9f1a2b4c6d8e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c
                  updated_at: 2026-06-11T08:30:00Z
                  attributes: { brand: Woptima, in_stock: true }
      responses:
        "200":
          description: Batch processed (may carry per-item errors; still `ok:true`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestBatchResponse" }
              example:
                ok: true
                data:
                  website_id: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2
                  received: 1
                  accepted: 1
                  skipped: 0
                  errored: 0
                  results:
                    - id: demo-prod-1
                      status: accepted
                      checksum: sha256:9f2b7c1d4e8a0b3c5d7e9f1a2b4c6d8e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c
                      raw_content_id: f3a1c2de-4b5a-46c7-88d9-0e1f2a3b4c5d
                      job_id: content.ingest:01J8ZQ7Y6K3M2N1P0Q9R8S7T6U
                meta: { requestId: c0ffee00-0000-4000-8000-000000000060, warning: verification_grace }
        "401": { $ref: "#/components/responses/ConnectorUnauthorized" }
        "403": { $ref: "#/components/responses/ConnectorScope" }
        "409":
          description: Idempotency-Key reused with a different body.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              example:
                ok: false
                error:
                  code: ingest.idempotency_conflict
                  message: Idempotency-Key reused with a different body.
                  type: https://docs.bainquet.online/errors/ingest.idempotency_conflict
                  status: 409
                  requestId: c0ffee00-0000-4000-8000-000000000061
                  retryable: false
                  details: {}
        "413":
          description: Batch/payload exceeds the per-tenant cap.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              example:
                ok: false
                error:
                  code: ingest.batch_too_large
                  message: Batch exceeds the maximum item count.
                  type: https://docs.bainquet.online/errors/ingest.batch_too_large
                  status: 413
                  requestId: c0ffee00-0000-4000-8000-000000000062
                  retryable: false
                  details: { maxBytes: 5242880, gotBytes: 7340032 }
        "400": { $ref: "#/components/responses/MissingIdempotencyKey" }
        "422":
          description: Whole-request rejection (`partial:false` with a bad item, or body validation).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              example:
                ok: false
                error:
                  code: ingest.batch_rejected
                  message: A bad item rejected the atomic batch.
                  type: https://docs.bainquet.online/errors/ingest.batch_rejected
                  status: 422
                  requestId: c0ffee00-0000-4000-8000-000000000063
                  retryable: false
                  details: {}
        "429": { $ref: "#/components/responses/QuotaExceeded" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/ingest/item:
    post:
      tags: [Ingestion]
      operationId: ingestItem
      summary: Ingest a single content item
      description: |
        Submits exactly one item (no `items[]` wrapper). **Connector principal
        only** (bearer + HMAC). A semantically invalid item surfaces as an
        envelope `422 ingest.item_invalid` (no sibling item to keep partial). The
        same HMAC + `Idempotency-Key` headers as `/v1/ingest/batch` are required.
      security:
        - connectorToken: []
      parameters:
        - $ref: "#/components/parameters/XSignature"
        - $ref: "#/components/parameters/XTimestamp"
        - $ref: "#/components/parameters/XNonce"
        - $ref: "#/components/parameters/XSiteDomain"
        - $ref: "#/components/parameters/XConnectorType"
        - $ref: "#/components/parameters/XConnectorVersion"
        - $ref: "#/components/parameters/XBodySha256"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IngestItemRequest" }
            example:
              website_id: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2
              item:
                type: page
                id: demo-page-about
                url: https://woptima-demo.bainquet.online/about
                title: About Woptima
                json: { name: Woptima, description: Woptima builds workspace hardware for focused work. }
                text: Woptima builds workspace hardware for focused work.
                language: en
                checksum: sha256:1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b
      responses:
        "200":
          description: Item processed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestItemResponse" }
              example:
                ok: true
                data:
                  id: demo-page-about
                  status: accepted
                  checksum: sha256:1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b
                  raw_content_id: a1b2c3d4-e5f6-4708-89a0-b1c2d3e4f5a6
                  job_id: content.ingest:01J8ZQ8A0B1C2D3E4F5G6H7J8K
                meta: { requestId: c0ffee00-0000-4000-8000-000000000064 }
        "401": { $ref: "#/components/responses/ConnectorUnauthorized" }
        "403": { $ref: "#/components/responses/ConnectorScope" }
        "400": { $ref: "#/components/responses/MissingIdempotencyKey" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
        "422":
          description: Item could not be queued.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              example:
                ok: false
                error:
                  code: ingest.item_invalid
                  message: type 'gadget' not in connector scope.
                  type: https://docs.bainquet.online/errors/ingest.item_invalid
                  status: 422
                  requestId: c0ffee00-0000-4000-8000-000000000065
                  retryable: false
                  details: { field: type, allowed: [page, product] }
        "429": { $ref: "#/components/responses/QuotaExceeded" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/ingest/delete:
    post:
      tags: [Ingestion]
      operationId: ingestDelete
      summary: Tombstone content items
      description: |
        Tombstones item ids (or hard-deletes when `reason=gdpr_erasure`), then
        triggers a graph rebuild + republish. **Connector principal only**.
        Deleting an unknown/already-tombstoned id is a no-op ack (still `ok:true`).
      security:
        - connectorToken: []
      parameters:
        - $ref: "#/components/parameters/XSignature"
        - $ref: "#/components/parameters/XTimestamp"
        - $ref: "#/components/parameters/XNonce"
        - $ref: "#/components/parameters/XSiteDomain"
        - $ref: "#/components/parameters/XConnectorType"
        - $ref: "#/components/parameters/XConnectorVersion"
        - $ref: "#/components/parameters/XBodySha256"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IngestDeleteRequest" }
            example:
              website_id: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2
              ids: [demo-prod-1, demo-prod-9]
              reason: source_deleted
      responses:
        "200":
          description: Delete processed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestDeleteResponse" }
              example:
                ok: true
                data:
                  website_id: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2
                  tombstoned: [demo-prod-1]
                  not_found: [demo-prod-9]
                  republish_job_id: node.publish:01J8ZQ9B1C2D3E4F5G6H7J8K9M
                meta: { requestId: c0ffee00-0000-4000-8000-000000000066 }
        "401": { $ref: "#/components/responses/ConnectorUnauthorized" }
        "403": { $ref: "#/components/responses/ConnectorScope" }
        "400": { $ref: "#/components/responses/MissingIdempotencyKey" }
        "409": { $ref: "#/components/responses/IdempotencyConflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/ingest/heartbeat:
    post:
      tags: [Ingestion]
      operationId: ingestHeartbeat
      summary: Connector liveness + cursor report
      description: |
        Reports liveness, sync cursor, and stats; receives a server-directed
        instruction (`continue`/`pause`/`backfill`) and the current verification
        state. **Connector principal only**. Latest-wins idempotent.
      security:
        - connectorToken: []
      parameters:
        - $ref: "#/components/parameters/XSignature"
        - $ref: "#/components/parameters/XTimestamp"
        - $ref: "#/components/parameters/XNonce"
        - $ref: "#/components/parameters/XSiteDomain"
        - $ref: "#/components/parameters/XConnectorType"
        - $ref: "#/components/parameters/XConnectorVersion"
        - $ref: "#/components/parameters/XBodySha256"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IngestHeartbeatRequest" }
            example:
              website_id: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2
              cursor: "page=42"
              connector_state: syncing
              stats: { items_seen: 12044, items_pending: 30 }
      responses:
        "200":
          description: Heartbeat acknowledged.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestHeartbeatResponse" }
              example:
                ok: true
                data:
                  next_action: continue
                  backoff_seconds: 0
                  server_time: 2026-06-12T00:00:00Z
                  verification_state: verified
                  cursor_ack: "page=42"
                meta: { requestId: c0ffee00-0000-4000-8000-000000000067 }
        "401": { $ref: "#/components/responses/ConnectorUnauthorized" }
        "403": { $ref: "#/components/responses/ConnectorScope" }
        "400": { $ref: "#/components/responses/MissingIdempotencyKey" }
        "409":
          description: Website not in a verified/grace state.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              example:
                ok: false
                error:
                  code: website.not_verified
                  message: Website is not verified.
                  type: https://docs.bainquet.online/errors/website.not_verified
                  status: 409
                  requestId: c0ffee00-0000-4000-8000-000000000068
                  retryable: false
                  details: { verificationState: pending }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /v1/ingest/schema:
    post:
      tags: [Ingestion]
      operationId: ingestSchema
      summary: Advertise connector field mappings
      description: |
        Advertises how connector fields populate canonical inputs (D-002
        self-config). **Connector principal only**. Replace-by-`(website,
        connectorType, version)`.
      security:
        - connectorToken: []
      parameters:
        - $ref: "#/components/parameters/XSignature"
        - $ref: "#/components/parameters/XTimestamp"
        - $ref: "#/components/parameters/XNonce"
        - $ref: "#/components/parameters/XSiteDomain"
        - $ref: "#/components/parameters/XConnectorType"
        - $ref: "#/components/parameters/XConnectorVersion"
        - $ref: "#/components/parameters/XBodySha256"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IngestSchemaRequest" }
            example:
              website_id: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2
              mappings:
                - source_path: $.acf.product_title
                  target: title
                - source_path: $.acf.body
                  target: text
                - source_path: $.acf.brand
                  target: attribute
                  attribute_name: brand
                  provenance_method: cms_field
      responses:
        "200":
          description: Mappings stored.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestSchemaResponse" }
              example:
                ok: true
                data:
                  website_id: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2
                  connector_type: wordpress
                  version: 1.0.0
                  accepted_mappings: 3
                  effective_at: 2026-06-12T00:01:00Z
                meta: { requestId: c0ffee00-0000-4000-8000-000000000069 }
        "401": { $ref: "#/components/responses/ConnectorUnauthorized" }
        "403": { $ref: "#/components/responses/ConnectorScope" }
        "400":
          description: Missing Idempotency-Key, or invalid mapping schema.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              example:
                ok: false
                error:
                  code: ingest.schema_invalid
                  message: Mapping set failed validation.
                  type: https://docs.bainquet.online/errors/ingest.schema_invalid
                  status: 400
                  requestId: c0ffee00-0000-4000-8000-00000000006a
                  retryable: false
                  details: {}
        "409":
          description: Mapping conflicts with an existing one.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              example:
                ok: false
                error:
                  code: ingest.schema_conflict
                  message: Mapping conflicts with an existing mapping for this connector.
                  type: https://docs.bainquet.online/errors/ingest.schema_conflict
                  status: 409
                  requestId: c0ffee00-0000-4000-8000-00000000006b
                  retryable: false
                  details: {}
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  # ───────────────────────────── Knowledge ────────────────────────────────────
  /v1/websites/{id}/knowledge/search:
    parameters:
      - $ref: "#/components/parameters/WebsiteIdPath"
    get:
      tags: [Knowledge]
      operationId: searchKnowledge
      summary: Search the knowledge graph
      description: Ranked entity/fact/chunk hits for a free-text query. Requires membership (viewer+). `q` is required.
      parameters:
        - name: q
          in: query
          required: true
          description: Free-text search query.
          schema: { type: string, minLength: 1 }
          example: standing desk
        - name: language
          in: query
          required: false
          description: BCP-47 language filter.
          schema: { type: string }
          example: en
        - name: limit
          in: query
          required: false
          description: Max results.
          schema: { type: integer, minimum: 1, maximum: 200 }
          example: 20
      responses:
        "200":
          description: Search hits.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SearchResultListResponse" }
              example:
                ok: true
                data:
                  - type: entity
                    id: ent_atlas_desk
                    title: Atlas Standing Desk
                    snippet: Height-adjustable workspace hardware by Woptima.
                    score: 0.94
                  - type: fact
                    id: fact_atlas_price
                    title: Atlas Standing Desk price
                    snippet: "599.00 EUR"
                    score: 0.88
                meta: { requestId: c0ffee00-0000-4000-8000-000000000070 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /v1/websites/{id}/entities/{eid}/facts:
    parameters:
      - $ref: "#/components/parameters/WebsiteIdPath"
      - $ref: "#/components/parameters/EntityIdPath"
    get:
      tags: [Knowledge]
      operationId: listEntityFacts
      summary: List an entity's facts
      description: Returns an entity's facts (hidden facts excluded). Requires membership (viewer+).
      parameters:
        - name: language
          in: query
          required: false
          description: BCP-47 language filter.
          schema: { type: string }
          example: en
      responses:
        "200":
          description: Facts.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FactListResponse" }
              example:
                ok: true
                data:
                  - id: fact_atlas_price
                    websiteId: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2
                    subject: Atlas Standing Desk
                    predicate: price
                    value: 599.0
                    currency: EUR
                    confidence: 0.95
                    importance: 5
                    freshness: fresh
                    language: en
                    provenanceMethod: cms_field
                    editorialStatus: approved
                    pinned: false
                    createdAt: 2026-06-11T09:00:00Z
                    updatedAt: 2026-06-11T09:00:00Z
                meta: { requestId: c0ffee00-0000-4000-8000-000000000071 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/websites/{id}/entities/{eid}/related:
    parameters:
      - $ref: "#/components/parameters/WebsiteIdPath"
      - $ref: "#/components/parameters/EntityIdPath"
    get:
      tags: [Knowledge]
      operationId: listRelatedEntities
      summary: List related entities
      description: Multi-hop neighbor edges from an entity. Requires membership (viewer+).
      parameters:
        - name: predicate
          in: query
          required: false
          description: Filter edges by predicate.
          schema: { type: string }
          example: same_brand
        - name: depth
          in: query
          required: false
          description: Traversal depth.
          schema: { type: integer, minimum: 1, maximum: 5 }
          example: 2
      responses:
        "200":
          description: Relationship edges.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RelationshipEdgeListResponse" }
              example:
                ok: true
                data:
                  - fromEntityId: ent_atlas_desk
                    toEntityId: ent_orbit_chair
                    predicate: same_brand
                    confidence: 0.9
                    depth: 1
                meta: { requestId: c0ffee00-0000-4000-8000-000000000072 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ───────────────────────────── Publishing ───────────────────────────────────
  /v1/websites/{id}/publish:
    parameters:
      - $ref: "#/components/parameters/WebsiteIdPath"
    post:
      tags: [Publishing]
      operationId: publishWebsite
      summary: Publish a node version
      description: |
        Builds and publishes a new immutable node version (synchronous in this
        build; production enqueues `node.publish`). Requires **editor**. Optional
        `?plan=` (`free`|`advanced`|`business`) and `?language=` query overrides.
      parameters:
        - $ref: "#/components/parameters/PlanQuery"
        - $ref: "#/components/parameters/LanguageQuery"
      responses:
        "200":
          description: Published.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PublishResponse" }
              example:
                ok: true
                data:
                  versionUlid: 01J8ZQAB2C3D4E5F6G7H8J9K0M
                  versionLabel: "2026-06-12.1"
                  cdnPath: /woptima-demo.bainquet.online/latest
                  files:
                    - path: index.json
                      checksum: sha256:7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b
                      bytes: 4096
                      records: 12
                      contentType: application/json
                      immutable: true
                    - path: facts.json
                      checksum: sha256:0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c
                      bytes: 18204
                      records: 88
                      contentType: application/json
                      immutable: true
                meta: { requestId: c0ffee00-0000-4000-8000-000000000080 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Concurrent publish or nothing to publish.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              example:
                ok: false
                error:
                  code: publish.nothing_to_publish
                  message: No derived node content available to publish.
                  type: https://docs.bainquet.online/errors/publish.nothing_to_publish
                  status: 409
                  requestId: c0ffee00-0000-4000-8000-000000000081
                  retryable: false
                  details: {}
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /v1/websites/{id}/node/preview:
    parameters:
      - $ref: "#/components/parameters/WebsiteIdPath"
    get:
      tags: [Publishing]
      operationId: previewNode
      summary: Preview the node files ("what AI sees")
      description: |
        Returns a summary of the node files that WOULD be published, without
        publishing. Requires membership (viewer+). Optional `?plan=` /
        `?language=`.

        NOTE: this exact path+method is registered by BOTH the `publisher-trigger`
        and `exporters` modules in the built server (whichever mounts first wins at
        runtime). They return the same `{ files }` shape; it is documented once
        here.
      parameters:
        - $ref: "#/components/parameters/PlanQuery"
        - $ref: "#/components/parameters/LanguageQuery"
      responses:
        "200":
          description: Node file summary.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PreviewResponse" }
              example:
                ok: true
                data:
                  files:
                    - path: index.json
                      records: 12
                      bytes: 4096
                    - path: facts.json
                      records: 88
                      bytes: 18204
                meta: { requestId: c0ffee00-0000-4000-8000-000000000082 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /v1/websites/{id}/node:
    parameters:
      - $ref: "#/components/parameters/WebsiteIdPath"
    delete:
      tags: [Publishing]
      operationId: takedownNode
      summary: Take down the published node
      description: Un-flips the `latest` pointer so AI consumers stop receiving the node (takedown). Requires **organization_admin**.
      responses:
        "200":
          description: Node tombstoned.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TakedownResponse" }
              example:
                ok: true
                data: { tombstoned: true }
                meta: { requestId: c0ffee00-0000-4000-8000-000000000083 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ───────────────────────────── Billing ──────────────────────────────────────
  /v1/organizations/{id}/entitlements:
    parameters:
      - $ref: "#/components/parameters/OrgIdPath"
    get:
      tags: [Billing]
      operationId: getEntitlements
      summary: Plan entitlements
      description: Returns the org's plan, capabilities, limits, and per-dimension usage. Requires membership (any role); admins bypass tenant scoping.
      responses:
        "200":
          description: Entitlements.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EntitlementsResponse" }
              example:
                ok: true
                data:
                  plan: free
                  status: active
                  capabilities:
                    "llm.extraction": false
                    embeddings: false
                    citations: true
                    openapi: true
                    mcp: false
                    "api.keys": false
                  limits:
                    "websites.max": 1
                    "monthly.publishes": 30
                    "api.keys.max": 0
                    "llm.tokens": 0
                  usage:
                    - dimension: monthly.publishes
                      used: 4
                      periodStart: 2026-06-01T00:00:00Z
                meta: { requestId: c0ffee00-0000-4000-8000-000000000090 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/organizations/{id}/usage:
    parameters:
      - $ref: "#/components/parameters/OrgIdPath"
    get:
      tags: [Billing]
      operationId: getUsage
      summary: Raw usage counters
      description: Returns raw per-dimension usage counters for the org. Requires membership (any role); admins bypass tenant scoping.
      responses:
        "200":
          description: Usage counters.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UsageResponse" }
              example:
                ok: true
                data:
                  usage:
                    - dimension: monthly.publishes
                      used: 4
                      periodStart: 2026-06-01T00:00:00Z
                    - dimension: ingest.items
                      used: 1042
                      periodStart: 2026-06-01T00:00:00Z
                meta: { requestId: c0ffee00-0000-4000-8000-000000000091 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ───────────────────────────── Admin ────────────────────────────────────────
  /v1/admin/organizations:
    get:
      tags: [Admin]
      operationId: adminListOrganizations
      summary: List all organizations (cross-tenant)
      description: Cross-tenant org list (cursor paginated). Requires **admin** or **super_admin**; every access is audited.
      parameters:
        - $ref: "#/components/parameters/CursorQuery"
        - $ref: "#/components/parameters/LimitQuery"
      responses:
        "200":
          description: Organizations.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminOrgListResponse" }
              example:
                ok: true
                data:
                  - id: a3b4c5d6-e7f8-49a0-b1c2-d3e4f5a6b7c8
                    name: Woptima Demo
                    slug: woptima-demo
                    plan: free
                    createdAt: 2026-06-10T08:00:00Z
                meta:
                  requestId: c0ffee00-0000-4000-8000-0000000000a0
                  cursor: eyJrIjoib3JnXzA0NCJ9
                  limit: 50
                  total: null
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AdminForbidden" }

  /v1/admin/organizations/{id}/members:
    parameters:
      - $ref: "#/components/parameters/OrgIdPath"
    get:
      tags: [Admin]
      operationId: adminListOrgMembers
      summary: List members of any organization
      description: Members of any org. Requires **admin** or **super_admin**; audited.
      responses:
        "200":
          description: Members.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminMemberListResponse" }
              example:
                ok: true
                data:
                  - userId: d1f0a2b3-c4d5-46e7-88f9-0a1b2c3d4e5f
                    email: web@woptima.com
                    role: organization_owner
                    joinedAt: 2026-06-10T08:00:00Z
                meta: { requestId: c0ffee00-0000-4000-8000-0000000000a1 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AdminForbidden" }

  /v1/admin/audit-log:
    get:
      tags: [Admin]
      operationId: adminListAuditLog
      summary: Read the audit log
      description: Audit log (cursor paginated). Requires **admin** or **super_admin**; audited.
      parameters:
        - $ref: "#/components/parameters/CursorQuery"
        - $ref: "#/components/parameters/LimitQuery"
      responses:
        "200":
          description: Audit rows.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminAuditLogListResponse" }
              example:
                ok: true
                data:
                  - id: 44444444-5555-4666-8777-888888888888
                    organizationId: a3b4c5d6-e7f8-49a0-b1c2-d3e4f5a6b7c8
                    websiteId: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2
                    actor: d1f0a2b3-c4d5-46e7-88f9-0a1b2c3d4e5f
                    action: demo.seed
                    details: { note: comprehensive demo seed }
                    createdAt: 2026-06-10T08:00:00Z
                meta:
                  requestId: c0ffee00-0000-4000-8000-0000000000a2
                  cursor: null
                  limit: 50
                  total: null
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AdminForbidden" }

  /v1/admin/overview:
    get:
      tags: [Admin]
      operationId: adminOverview
      summary: Platform aggregate counts
      description: Platform-wide aggregate counts. Requires **admin** or **super_admin**; audited.
      responses:
        "200":
          description: Aggregates.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AdminOverviewResponse" }
              example:
                ok: true
                data:
                  organizations: 12
                  websites: 19
                  publishedNodeWebsites: 8
                  verifiedWebsites: 11
                  verifiedRate: 0.58
                meta: { requestId: c0ffee00-0000-4000-8000-0000000000a3 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AdminForbidden" }

  /v1/admin/impersonate:
    post:
      tags: [Admin]
      operationId: adminImpersonate
      summary: Mint an impersonation token
      description: Mints a short-lived impersonation access token for a target user (F-035). Requires **admin** or **super_admin**; audited; `justification` ≥10 chars.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ImpersonateRequest" }
            example:
              targetUserId: e2f1b3c4-d5e6-47f8-99a0-b1c2d3e4f5a6
              justification: Investigating a publish failure reported in ticket WOP-1421.
      responses:
        "200":
          description: Impersonation token.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImpersonateResponse" }
              example:
                ok: true
                data:
                  accessToken: eyJhbGciOiJFZERTQSJ9.imp.sig
                  tokenType: Bearer
                  expiresIn: 600
                  impersonating: { userId: e2f1b3c4-d5e6-47f8-99a0-b1c2d3e4f5a6 }
                meta: { requestId: c0ffee00-0000-4000-8000-0000000000a4 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/AdminForbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  # ───────────────────────────── Analytics ────────────────────────────────────
  /v1/websites/{id}/analytics:
    parameters:
      - $ref: "#/components/parameters/WebsiteIdPath"
    get:
      tags: [Analytics]
      operationId: getWebsiteAnalytics
      summary: Read website analytics rollups
      description: Composed analytics rollups for a website. Requires a user access token (any role; light tenant scope).
      responses:
        "200":
          description: Analytics.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AnalyticsResponse" }
              example:
                ok: true
                data:
                  websiteId: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2
                  available: true
                  series:
                    - metric: node.reads
                      value: 1820
                      periodStart: 2026-06-01T00:00:00Z
                    - metric: facts.published
                      value: 88
                      periodStart: 2026-06-01T00:00:00Z
                meta: { requestId: c0ffee00-0000-4000-8000-0000000000b0 }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ───────────────────────────── Waitlist ─────────────────────────────────────
  /v1/waitlist:
    post:
      tags: [Waitlist]
      operationId: submitWaitlist
      summary: Join the waitlist
      description: |
        Public marketing waitlist capture. Always `202` on a valid submit; a
        duplicate email returns the SAME `202` (membership never leaked). No auth;
        abuse-controlled by the pre-auth rate limiter.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WaitlistRequest" }
            example:
              email: future-customer@woptima.com
              source: landing_hero
              consent: true
      responses:
        "202":
          description: Capture accepted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WaitlistResponse" }
              example:
                ok: true
                data: { status: accepted }
                meta: { requestId: c0ffee00-0000-4000-8000-0000000000c0 }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    userJwt:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        User access JWT (`Authorization: Bearer <jwt>`), `EdDSA`/`RS256`, ~15 min
        TTL. Carries an RBAC role. Renewed via `/v1/auth/refresh` using the
        httpOnly `bnq_refresh` cookie or a body token.
    connectorToken:
      type: http
      scheme: bearer
      description: |
        Connector credential for ingestion. `Authorization: Bearer
        <connectorId>.<secret>`. Each request MUST additionally carry the HMAC
        signing headers (`X-Signature`, `X-Timestamp`, `X-Nonce`, `X-Site-Domain`,
        `X-Connector-Type`, `X-Connector-Version`) and an `Idempotency-Key` — these
        are declared as required parameters on each ingest operation.
        `X-Signature` = lowercase-hex HMAC-SHA256 over
        `METHOD\npath\nbodySha256\nissuedAt\nnonce\nwebsiteId` using an
        HKDF-SHA256 per-request key. Replay window ±300s.
    apiKey:
      type: apiKey
      in: header
      name: X-Api-Key
      description: |
        Business-plan customer API key (`X-Api-Key: bnq_live_…`, or
        `Authorization: Bearer bnq_live_…`). Scoped to org/website, rate-limited.
        Defined for completeness; key-lifecycle endpoints are not yet exposed as
        REST operations in the built routers.

  parameters:
    OrgIdPath:
      name: id
      in: path
      required: true
      description: Organization id (uuid).
      schema: { type: string, format: uuid }
      example: a3b4c5d6-e7f8-49a0-b1c2-d3e4f5a6b7c8
    OrgIdInOrgPath:
      name: orgId
      in: path
      required: true
      description: Organization id (uuid).
      schema: { type: string, format: uuid }
      example: a3b4c5d6-e7f8-49a0-b1c2-d3e4f5a6b7c8
    UserIdPath:
      name: userId
      in: path
      required: true
      description: User id (uuid).
      schema: { type: string, format: uuid }
      example: e2f1b3c4-d5e6-47f8-99a0-b1c2d3e4f5a6
    WebsiteIdPath:
      name: id
      in: path
      required: true
      description: Website id (uuid).
      schema: { type: string, format: uuid }
      example: b7c8d9e0-f1a2-43b4-85c6-d7e8f9a0b1c2
    ConnectorIdPath:
      name: cid
      in: path
      required: true
      description: Connector id (uuid).
      schema: { type: string, format: uuid }
      example: c1d2e3f4-a5b6-47c8-99d0-e1f2a3b4c5d6
    EntityIdPath:
      name: eid
      in: path
      required: true
      description: Entity id.
      schema: { type: string }
      example: ent_atlas_desk
    CursorQuery:
      name: cursor
      in: query
      required: false
      description: Opaque forward cursor (echo `meta.cursor` from the previous page).
      schema: { type: string }
    LimitQuery:
      name: limit
      in: query
      required: false
      description: Page size (clamped to a server max of 200; default 50).
      schema: { type: integer, minimum: 1, maximum: 200 }
      example: 50
    PlanQuery:
      name: plan
      in: query
      required: false
      description: Plan-tier override for the node build.
      schema: { type: string, enum: [free, advanced, business] }
      example: free
    LanguageQuery:
      name: language
      in: query
      required: false
      description: BCP-47 language to build/preview.
      schema: { type: string }
      example: en
    # Connector HMAC headers (required on every ingest operation)
    XSignature:
      name: X-Signature
      in: header
      required: true
      description: Lowercase-hex HMAC-SHA256 of the canonical signing string (F-032).
      schema: { type: string }
      example: 3f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a
    XTimestamp:
      name: X-Timestamp
      in: header
      required: true
      description: Unix seconds; must be within ±300s of server time (replay window).
      schema: { type: integer, format: int64 }
      example: 1750000000
    XNonce:
      name: X-Nonce
      in: header
      required: true
      description: Random per-request nonce; single-use within the replay window.
      schema: { type: string }
      example: 9b1c0d9e8f7a6b5c
    XSiteDomain:
      name: X-Site-Domain
      in: header
      required: true
      description: The site domain the items belong to; item URLs must match this host (or a verified alias).
      schema: { type: string }
      example: woptima-demo.bainquet.online
    XConnectorType:
      name: X-Connector-Type
      in: header
      required: true
      description: Connector type advertising the request.
      schema: { type: string }
      example: wordpress
    XConnectorVersion:
      name: X-Connector-Version
      in: header
      required: true
      description: Connector/plugin semver.
      schema: { type: string }
      example: 1.0.0
    XBodySha256:
      name: X-Body-Sha256
      in: header
      required: false
      description: Optional lowercase-hex sha256 of the raw body; server recomputes and cross-checks.
      schema: { type: string }
      example: 7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: UUID v4 deduplicating the logical operation (F-004). Required on every ingest call.
      schema: { type: string, format: uuid }
      example: 7f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f

  responses:
    ValidationFailed:
      description: Request validation failed (`validation.failed`).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            ok: false
            error:
              code: validation.failed
              message: Request validation failed.
              type: https://docs.bainquet.online/errors/validation.failed
              status: 422
              requestId: c0ffee00-0000-4000-8000-0000000000f0
              retryable: false
              details:
                fields:
                  - field: email
                    code: format
                    message: Must be a valid email address.
    Unauthorized:
      description: Missing or invalid user credential (`auth.unauthenticated`).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            ok: false
            error:
              code: auth.unauthenticated
              message: Authentication required.
              type: https://docs.bainquet.online/errors/auth.unauthenticated
              status: 401
              requestId: c0ffee00-0000-4000-8000-0000000000f1
              retryable: false
              details: {}
    Forbidden:
      description: Authenticated but not permitted, or cross-tenant (`auth.forbidden` / `auth.tenant_mismatch`).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            ok: false
            error:
              code: auth.tenant_mismatch
              message: Not a member of this organization.
              type: https://docs.bainquet.online/errors/auth.tenant_mismatch
              status: 403
              requestId: c0ffee00-0000-4000-8000-0000000000f2
              retryable: false
              details: {}
    NotFound:
      description: Resource not found or not visible (`website.not_found`).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            ok: false
            error:
              code: website.not_found
              message: Website not found.
              type: https://docs.bainquet.online/errors/website.not_found
              status: 404
              requestId: c0ffee00-0000-4000-8000-0000000000f3
              retryable: false
              details: {}
    AdminForbidden:
      description: Action restricted to admin / super_admin (`admin.action_forbidden`).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            ok: false
            error:
              code: admin.action_forbidden
              message: Action restricted to platform administrators.
              type: https://docs.bainquet.online/errors/admin.action_forbidden
              status: 403
              requestId: c0ffee00-0000-4000-8000-0000000000f4
              retryable: false
              details: {}
    RateLimited:
      description: Rate limited (`quota.rate_limited`); honor `Retry-After`.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            ok: false
            error:
              code: quota.rate_limited
              message: Too many requests.
              type: https://docs.bainquet.online/errors/quota.rate_limited
              status: 429
              requestId: c0ffee00-0000-4000-8000-0000000000f5
              retryable: true
              details: {}
    QuotaExceeded:
      description: Plan/storage quota exceeded (`quota.exceeded`); honor `Retry-After`.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            ok: false
            error:
              code: quota.exceeded
              message: Plan quota reached.
              type: https://docs.bainquet.online/errors/quota.exceeded
              status: 429
              requestId: c0ffee00-0000-4000-8000-0000000000f6
              retryable: true
              details: { limit: 1000, used: 1000, resetAt: 2026-07-01T00:00:00Z }
    ServiceUnavailable:
      description: A dependency is temporarily down (`service.unavailable`); honor `Retry-After`.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            ok: false
            error:
              code: service.unavailable
              message: Service temporarily unavailable; retry shortly.
              type: https://docs.bainquet.online/errors/service.unavailable
              status: 503
              requestId: c0ffee00-0000-4000-8000-0000000000f7
              retryable: true
              details: {}
    ConnectorUnauthorized:
      description: Connector bearer/HMAC failure (`auth.invalid_signature` / `auth.timestamp_skew` / `auth.unauthenticated`).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            ok: false
            error:
              code: auth.invalid_signature
              message: Connector HMAC signature did not verify.
              type: https://docs.bainquet.online/errors/auth.invalid_signature
              status: 401
              requestId: c0ffee00-0000-4000-8000-0000000000f8
              retryable: false
              details: {}
    ConnectorScope:
      description: Connector out of scope or revoked (`connector.scope_violation` / `connector.token_revoked`).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            ok: false
            error:
              code: connector.scope_violation
              message: Item outside the connector's website/sourceType scope.
              type: https://docs.bainquet.online/errors/connector.scope_violation
              status: 403
              requestId: c0ffee00-0000-4000-8000-0000000000f9
              retryable: false
              details: {}
    MissingIdempotencyKey:
      description: Missing Idempotency-Key header (`validation.missing_idempotency_key`).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            ok: false
            error:
              code: validation.missing_idempotency_key
              message: Missing Idempotency-Key header.
              type: https://docs.bainquet.online/errors/validation.missing_idempotency_key
              status: 400
              requestId: c0ffee00-0000-4000-8000-0000000000fa
              retryable: false
              details: {}
    Conflict:
      description: The request conflicts with the current state (e.g. a transfer is not pending, already accepted, or expired).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            ok: false
            error:
              code: organization.transfer_not_pending
              message: No pending transfer to act on.
              type: https://docs.bainquet.online/errors/organization.transfer_not_pending
              status: 409
              requestId: c0ffee00-0000-4000-8000-0000000000fb
              retryable: false
              details: {}
    IdempotencyConflict:
      description: Idempotency-Key reused with a different body (`ingest.idempotency_conflict`).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            ok: false
            error:
              code: ingest.idempotency_conflict
              message: Idempotency-Key reused with a different body.
              type: https://docs.bainquet.online/errors/ingest.idempotency_conflict
              status: 409
              requestId: c0ffee00-0000-4000-8000-0000000000fb
              retryable: false
              details: {}

  schemas:
    # ── Envelope primitives ──────────────────────────────────────────────────
    Meta:
      type: object
      description: Response metadata. Present on paginated/list responses and whenever requestId is surfaced.
      properties:
        requestId:
          type: string
          format: uuid
          description: Correlation id; echoes X-Request-Id or is server-minted.
        cursor:
          type: [string, "null"]
          description: Opaque next-page cursor; null means end of list; absent means not a paginated collection.
        limit:
          type: integer
          description: Page size actually applied (after clamping).
        total:
          type: [integer, "null"]
          description: Optional total count; null when unavailable.
        warning:
          type: string
          description: Non-fatal advisory (e.g. `verification_grace` on ingest while a website is in grace).
      additionalProperties: true
    ErrorBody:
      type: object
      required: [code, message, type, status, requestId, retryable]
      description: |
        The error payload (RFC 9457 superset). `code` is the primary programmatic
        key, always one of the canonical catalogue codes
        (`packages/shared/src/error-codes.ts`, contractVersion 0.4.0).
      properties:
        code:
          type: string
          description: |
            Canonical machine code. One of:
            auth.unauthenticated, auth.invalid_credentials, auth.token_expired,
            auth.token_revoked, auth.refresh_invalid, auth.refresh_reuse_detected,
            auth.session_revoked, auth.token_version_stale, auth.invalid_signature,
            auth.timestamp_skew, auth.replay_detected, auth.mfa_required,
            auth.mfa_invalid, auth.email_unverified, auth.email_taken,
            auth.forbidden, auth.forbidden_role, auth.forbidden_scope,
            auth.scope_violation, auth.tenant_mismatch, auth.principal_role_misuse,
            auth.service_credential_external, auth.reauth_required,
            validation.failed, validation.malformed_body,
            validation.unsupported_field, validation.missing_idempotency_key,
            validation.stale_cursor, validation.scope_unknown,
            validation.scope_forbidden, validation.ambiguous_credential,
            website.not_found, website.not_verified, verification.in_grace,
            verification.failed, verification.method_unsupported,
            connector.unknown_type, connector.version_unsupported,
            connector.token_revoked, connector.scope_violation,
            connector.replay_detected, connector.rotation_in_progress,
            ingest.payload_too_large, ingest.batch_too_large, ingest.duplicate,
            ingest.idempotency_conflict, ingest.item_rejected, ingest.item_invalid,
            ingest.unknown_item, ingest.batch_rejected, ingest.schema_conflict,
            ingest.schema_invalid, quota.exceeded, quota.rate_limited,
            billing.payment_required, billing.plan_required,
            billing.subscription_inactive, publish.conflict,
            publish.nothing_to_publish, publish.review_required,
            admin.action_forbidden, internal.error, service.unavailable,
            privacy.erasure_forbidden, privacy.erasure_in_progress,
            privacy.subject_not_found, privacy.takedown_forbidden,
            privacy.takedown_target_not_found, privacy.consent_required.
          enum:
            - auth.unauthenticated
            - auth.invalid_credentials
            - auth.token_expired
            - auth.token_revoked
            - auth.refresh_invalid
            - auth.refresh_reuse_detected
            - auth.session_revoked
            - auth.token_version_stale
            - auth.invalid_signature
            - auth.timestamp_skew
            - auth.replay_detected
            - auth.mfa_required
            - auth.mfa_invalid
            - auth.email_unverified
            - auth.email_taken
            - auth.forbidden
            - auth.forbidden_role
            - auth.forbidden_scope
            - auth.scope_violation
            - auth.tenant_mismatch
            - auth.principal_role_misuse
            - auth.service_credential_external
            - auth.reauth_required
            - validation.failed
            - validation.malformed_body
            - validation.unsupported_field
            - validation.missing_idempotency_key
            - validation.stale_cursor
            - validation.scope_unknown
            - validation.scope_forbidden
            - validation.ambiguous_credential
            - website.not_found
            - website.not_verified
            - verification.in_grace
            - verification.failed
            - verification.method_unsupported
            - connector.unknown_type
            - connector.version_unsupported
            - connector.token_revoked
            - connector.scope_violation
            - connector.replay_detected
            - connector.rotation_in_progress
            - ingest.payload_too_large
            - ingest.batch_too_large
            - ingest.duplicate
            - ingest.idempotency_conflict
            - ingest.item_rejected
            - ingest.item_invalid
            - ingest.unknown_item
            - ingest.batch_rejected
            - ingest.schema_conflict
            - ingest.schema_invalid
            - quota.exceeded
            - quota.rate_limited
            - billing.payment_required
            - billing.plan_required
            - billing.subscription_inactive
            - publish.conflict
            - publish.nothing_to_publish
            - publish.review_required
            - admin.action_forbidden
            - internal.error
            - service.unavailable
            - privacy.erasure_forbidden
            - privacy.erasure_in_progress
            - privacy.subject_not_found
            - privacy.takedown_forbidden
            - privacy.takedown_target_not_found
            - privacy.consent_required
        message:
          type: string
          description: Human-readable English explanation (safe to log, not for verbatim end-user display).
        type:
          type: string
          format: uri
          description: Dereferenceable docs URI, `https://docs.bainquet.online/errors/{code}`.
        status:
          type: integer
          description: HTTP status, mirrors the response status line.
        requestId:
          type: string
          format: uuid
          description: Correlation id.
        retryable:
          type: boolean
          description: Whether the client MAY retry the same request (honor Retry-After).
        details:
          type: [object, "null"]
          description: Code-specific structured context; never secrets/PII/SQL/stack traces.
          additionalProperties: true
    ErrorResponse:
      type: object
      required: [ok, error]
      description: Error envelope (ok=false).
      properties:
        ok:
          type: boolean
          enum: [false]
        error: { $ref: "#/components/schemas/ErrorBody" }
    SuccessEnvelope:
      type: object
      required: [ok]
      description: Base success envelope; concrete responses override `data` via allOf.
      properties:
        ok:
          type: boolean
          enum: [true]
        data: {}
        meta: { $ref: "#/components/schemas/Meta" }

    # ── System data ──────────────────────────────────────────────────────────
    RootResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                service: { type: string }
                message: { type: string }
    HealthzResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                status: { type: string }
                service: { type: string }
    ReadyzResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                ready: { type: boolean }
                service: { type: string }

    # ── Shared enums / value objects ─────────────────────────────────────────
    Role:
      type: string
      description: RBAC role ladder (low→high privilege).
      enum: [viewer, editor, organization_admin, organization_owner, admin, super_admin]
    VerificationState:
      type: string
      enum: [pending, verified, failed, grace, revoked]
    VerificationMethod:
      type: string
      enum: [dns_txt, plugin_signed, well_known, meta_tag, manual]
    ConnectorType:
      type: string
      enum: [wordpress, woocommerce, shopify, drupal, joomla, magento, laravel, symfony, nextjs, nuxt, astro, webflow, wix, ghost, api, crawler]
    SourceType:
      type: string
      enum: [page, post, product, category, collection, faq, review, event, location, profile, doc, media, custom]
    DeleteReason:
      type: string
      enum: [source_deleted, gdpr_erasure, connector_resync, takedown]
    MappingTarget:
      type: string
      enum: [title, text, language, canonical_url, updated_at, attribute]
    WaitlistSource:
      type: string
      enum: [landing_hero, pricing, wedge, docs, blog, footer, api]
    Principal:
      type: object
      description: The login/refresh principal projection (a user principal view).
      required: [kind, id, scopes, mfaSatisfied]
      properties:
        kind: { type: string, enum: [user] }
        id: { type: string, format: uuid }
        organizationId: { type: [string, "null"], format: uuid }
        role:
          oneOf:
            - { $ref: "#/components/schemas/Role" }
            - { type: "null" }
        scopes:
          type: array
          items: { type: string }
        mfaSatisfied: { type: boolean }

    # ── Auth ─────────────────────────────────────────────────────────────────
    RegisterRequest:
      type: object
      required: [email, password]
      properties:
        email: { type: string, format: email, maxLength: 254 }
        password: { type: string, minLength: 12, description: "≥12 chars; must not equal the email." }
        orgName: { type: string, minLength: 1, maxLength: 200, description: "Optional first organization name." }
    RegisterResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              required: [userId, emailVerificationSent]
              properties:
                userId: { type: string, format: uuid }
                emailVerificationSent: { type: boolean, enum: [true] }
    VerifyEmailRequest:
      type: object
      required: [token]
      properties:
        token: { type: string, minLength: 1 }
    VerifyEmailResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                verified: { type: boolean, enum: [true] }
    LoginRequest:
      type: object
      required: [email, password]
      properties:
        email: { type: string, format: email }
        password: { type: string, minLength: 1 }
        otp: { type: string, pattern: "^[0-9]{6}$", description: "6-digit TOTP when MFA is enabled." }
    RefreshRequest:
      type: object
      description: Refresh uses the body token OR the bnq_refresh cookie.
      properties:
        refreshToken: { type: string, minLength: 1 }
    TokenPair:
      type: object
      required: [accessToken, tokenType, expiresIn, refreshToken, principal]
      properties:
        accessToken: { type: string }
        tokenType: { type: string, enum: [Bearer] }
        expiresIn: { type: integer, description: "Access-token lifetime in seconds." }
        refreshToken: { type: string }
        principal: { $ref: "#/components/schemas/Principal" }
    TokenPairResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/TokenPair" }
    PasswordChangeRequest:
      type: object
      required: [current, next]
      properties:
        current: { type: string, minLength: 1 }
        next: { type: string, minLength: 12, description: "≥12 chars; must differ from current." }
    ForgotPasswordRequest:
      type: object
      required: [email]
      properties:
        email: { type: string, format: email }
    ForgotPasswordResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                requested: { type: boolean, enum: [true] }
    ResetPasswordRequest:
      type: object
      required: [token, next]
      properties:
        token: { type: string, minLength: 1 }
        next: { type: string, minLength: 12 }
    Me:
      type: object
      required: [id, email, memberships, mfaEnabled]
      properties:
        id: { type: string, format: uuid }
        email: { type: string, format: email }
        role:
          oneOf:
            - { $ref: "#/components/schemas/Role" }
            - { type: "null" }
        org: { type: [string, "null"], format: uuid }
        memberships:
          type: array
          items:
            type: object
            required: [organizationId, role]
            properties:
              organizationId: { type: string, format: uuid }
              role: { $ref: "#/components/schemas/Role" }
        mfaEnabled: { type: boolean }
    MeResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/Me" }

    # ── Organizations ─────────────────────────────────────────────────────────
    Organization:
      type: object
      required: [id, name, plan, billingStatus, createdAt, updatedAt]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        slug: { type: [string, "null"] }
        ownerUserId: { type: [string, "null"], format: uuid }
        plan: { type: string }
        billingStatus: { type: string }
        billingRef: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    OrganizationResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/Organization" }
    OrganizationListResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/Organization" }
    CreateOrganizationRequest:
      type: object
      required: [name]
      properties:
        name: { type: string, minLength: 1, maxLength: 128 }
        slug: { type: string, pattern: "^[a-z0-9-]{3,64}$", description: "Auto-derived from name if omitted." }
    UpdateOrganizationRequest:
      type: object
      additionalProperties: false
      description: Partial update (all optional); unknown fields rejected.
      properties:
        name: { type: string, minLength: 1, maxLength: 128 }
        slug: { type: string, pattern: "^[a-z0-9-]{3,64}$" }
        billingEmail: { type: string, format: email }
    Membership:
      type: object
      required: [id, userId, organizationId, role, createdAt]
      properties:
        id: { type: string, format: uuid }
        userId: { type: string, format: uuid }
        organizationId: { type: string, format: uuid }
        role: { $ref: "#/components/schemas/Role" }
        createdAt: { type: string, format: date-time }
    MembershipResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/Membership" }
    MembershipListResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/Membership" }
    InviteMemberRequest:
      type: object
      required: [role]
      description: Exactly one of email/userId is required.
      properties:
        email: { type: string, format: email }
        userId: { type: string, format: uuid }
        role: { $ref: "#/components/schemas/Role" }
    UpdateMemberRoleRequest:
      type: object
      required: [role]
      properties:
        role: { $ref: "#/components/schemas/Role" }
    EraseOrganizationRequest:
      type: object
      required: [confirm]
      properties:
        confirm: { type: string, minLength: 1, description: "Must equal the org slug or name." }
    EraseOrganizationResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              required: [erasureRequestId, status]
              properties:
                erasureRequestId: { type: string, format: uuid }
                status: { type: string, example: completed }

    # ── Websites ───────────────────────────────────────────────────────────────
    Website:
      type: object
      required: [id, organizationId, domain, normalizedDomain, status, primaryLanguage, additionalLanguages, reviewMode, reviewImportanceThreshold, piiOptin, createdAt, updatedAt]
      properties:
        id: { type: string, format: uuid }
        organizationId: { type: string, format: uuid }
        domain: { type: string }
        normalizedDomain: { type: string }
        status: { type: string, enum: [pending, verified, suspended] }
        primaryLanguage: { type: string }
        additionalLanguages:
          type: array
          items: { type: string }
        cmsDetected: { type: string }
        pluginDetected: { type: boolean }
        reviewMode: { type: string, enum: ["off", required] }
        reviewImportanceThreshold: { type: integer, minimum: 1, maximum: 5 }
        piiOptin:
          type: object
          additionalProperties: true
        lastSyncAt: { type: string, format: date-time }
        lastPublishAt: { type: string, format: date-time }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    WebsiteResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/Website" }
    WebsiteListResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/Website" }
    CreateWebsiteRequest:
      type: object
      required: [domain]
      properties:
        domain: { type: string, minLength: 1, maxLength: 253 }
        primaryLanguage: { type: string, description: "BCP-47 tag." }
        additionalLanguages:
          type: array
          maxItems: 50
          items: { type: string }
    UpdateWebsiteRequest:
      type: object
      additionalProperties: false
      description: Partial update; unknown fields rejected.
      properties:
        reviewMode: { type: string, enum: ["off", required] }
        reviewImportanceThreshold: { type: integer, minimum: 1, maximum: 5 }
        piiOptin:
          type: object
          additionalProperties: true
        additionalLanguages:
          type: array
          maxItems: 50
          items: { type: string }

    # ── Verification ───────────────────────────────────────────────────────────
    Verification:
      type: object
      required: [websiteId, state, method, failureCount, createdAt, updatedAt]
      properties:
        websiteId: { type: string, format: uuid }
        state: { $ref: "#/components/schemas/VerificationState" }
        method: { type: string }
        tier: { type: [string, "null"] }
        lastCheckedAt: { type: string, format: date-time }
        failureCount: { type: integer }
        graceUntil: { type: string, format: date-time }
        verifiedAt: { type: string, format: date-time }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    VerificationResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/Verification" }
    VerificationChallenge:
      allOf:
        - $ref: "#/components/schemas/Verification"
        - type: object
          required: [token, dnsRecordName, instructions]
          properties:
            token: { type: string }
            dnsRecordName: { type: string }
            instructions: { type: string }
    VerificationChallengeResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/VerificationChallenge" }
    VerificationChallengeRequest:
      type: object
      additionalProperties: false
      required: [method]
      properties:
        method: { $ref: "#/components/schemas/VerificationMethod" }
    VerificationCheckRequest:
      type: object
      additionalProperties: false
      description: Optional signed-challenge fields for plugin_signed; empty body re-runs dns/http methods.
      properties:
        nonce: { type: string, minLength: 1 }
        ts: { type: integer }
        signature: { type: string, minLength: 1 }
    VerificationCheckResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              required: [result, verification]
              properties:
                result: { type: string, enum: [verified, failed, transient] }
                verification: { $ref: "#/components/schemas/Verification" }

    # ── Connectors ─────────────────────────────────────────────────────────────
    ConnectorScope:
      type: object
      required: [websiteId, allowedSourceTypes, allowedActions]
      properties:
        websiteId: { type: string, format: uuid }
        allowedSourceTypes:
          type: array
          items: { type: string }
        allowedActions:
          type: array
          items: { type: string }
          description: Capability scopes (e.g. ingest:write, ingest:delete, ingest:schema, heartbeat:write).
    IssuedConnector:
      type: object
      required: [connectorId, token, scope]
      properties:
        connectorId: { type: string, format: uuid }
        token: { type: string, description: "<connectorId>.<secret>; shown ONCE." }
        scope: { $ref: "#/components/schemas/ConnectorScope" }
    IssuedConnectorResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/IssuedConnector" }
    Connector:
      type: object
      required: [id, websiteId, organizationId, type, version, status, scope, tokenVersion, createdAt, updatedAt]
      properties:
        id: { type: string, format: uuid }
        websiteId: { type: string, format: uuid }
        organizationId: { type: string, format: uuid }
        type: { type: string }
        version: { type: string }
        status: { type: string, enum: [active, suspended, revoked] }
        scope: { $ref: "#/components/schemas/ConnectorScope" }
        tokenVersion: { type: integer }
        lastUsedAt: { type: [string, "null"], format: date-time }
        lastHeartbeatAt: { type: [string, "null"], format: date-time }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    ConnectorListResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/Connector" }
    CreateConnectorRequest:
      type: object
      required: [type]
      properties:
        type: { $ref: "#/components/schemas/ConnectorType" }
        allowedSourceTypes:
          type: array
          maxItems: 64
          items: { type: string }
          description: RawContentItem.sourceType values this connector may ingest (scope).
        version: { type: string, minLength: 1, maxLength: 32, description: "Defaults to 1.0.0." }
    RotateConnectorRequest:
      type: object
      additionalProperties: false
      description: No body fields.

    # ── Ingestion ──────────────────────────────────────────────────────────────
    IngestItem:
      type: object
      additionalProperties: false
      required: [type, id, url, checksum]
      description: A single content item. At least one of html/text/json must be present.
      properties:
        type: { $ref: "#/components/schemas/SourceType" }
        id: { type: string, minLength: 1, maxLength: 256, description: "Connector-stable opaque id (not a uuid)." }
        url: { type: string, format: uri }
        canonical_url: { type: string, format: uri }
        title: { type: string, maxLength: 1024 }
        html: { type: string }
        text: { type: string }
        json:
          type: object
          additionalProperties: true
        language: { type: string, description: "BCP-47 tag." }
        checksum: { type: string, pattern: "^sha256:[0-9a-f]{64}$" }
        updated_at: { type: string, format: date-time, description: "RFC3339 with explicit UTC Z." }
        attributes:
          type: object
          additionalProperties: true
    IngestBatchRequest:
      type: object
      additionalProperties: false
      required: [website_id, items]
      properties:
        website_id: { type: string, minLength: 1 }
        items:
          type: array
          minItems: 1
          maxItems: 500
          items: { $ref: "#/components/schemas/IngestItem" }
        partial: { type: boolean, default: true, description: "If false, any invalid item rejects the whole batch atomically." }
    IngestItemRequest:
      type: object
      additionalProperties: false
      required: [website_id, item]
      properties:
        website_id: { type: string, minLength: 1 }
        item: { $ref: "#/components/schemas/IngestItem" }
    IngestDeleteRequest:
      type: object
      additionalProperties: false
      required: [website_id, ids]
      properties:
        website_id: { type: string, minLength: 1 }
        ids:
          type: array
          minItems: 1
          maxItems: 500
          items: { type: string, minLength: 1, maxLength: 256 }
        reason: { $ref: "#/components/schemas/DeleteReason" }
    IngestHeartbeatRequest:
      type: object
      additionalProperties: false
      required: [website_id]
      properties:
        website_id: { type: string, minLength: 1 }
        cursor: { type: string }
        stats:
          type: object
          additionalProperties: true
          properties:
            items_seen: { type: integer, minimum: 0 }
            items_pending: { type: integer, minimum: 0 }
            last_error: { type: string }
        connector_state: { type: string, enum: [idle, syncing, backfilling, error] }
    FieldMapping:
      type: object
      additionalProperties: false
      required: [source_path, target]
      properties:
        source_path: { type: string, minLength: 1 }
        target: { $ref: "#/components/schemas/MappingTarget" }
        attribute_name: { type: string, minLength: 1, description: "Required when target=attribute." }
        provenance_method: { type: string, minLength: 1, description: "Data-model provenance ladder; never llm_inferred." }
    IngestSchemaRequest:
      type: object
      additionalProperties: false
      required: [website_id, mappings]
      properties:
        website_id: { type: string, minLength: 1 }
        mappings:
          type: array
          minItems: 1
          maxItems: 256
          items: { $ref: "#/components/schemas/FieldMapping" }
    IngestItemResult:
      type: object
      required: [id, status]
      properties:
        id: { type: string, description: "Echoes the connector item.id (opaque, not uuid)." }
        status: { type: string, enum: [accepted, skipped, error] }
        checksum: { type: string, description: "sha256:<hex>; present on accepted/skipped." }
        raw_content_id: { type: string, format: uuid, description: "Server-minted row id; present on accepted." }
        job_id: { type: string, description: "content.ingest job id; present on accepted." }
        reason: { type: string, enum: [idempotent_replay, unchanged_checksum, tombstoned], description: "Present on skipped." }
        error:
          type: object
          description: Present only on status=error.
          properties:
            code: { type: string }
            message: { type: string }
            details:
              type: object
              additionalProperties: true
    IngestBatchResult:
      type: object
      required: [website_id, received, accepted, skipped, errored, results]
      properties:
        website_id: { type: string }
        received: { type: integer }
        accepted: { type: integer }
        skipped: { type: integer }
        errored: { type: integer }
        results:
          type: array
          items: { $ref: "#/components/schemas/IngestItemResult" }
    IngestBatchResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/IngestBatchResult" }
    IngestItemResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/IngestItemResult" }
    IngestDeleteResult:
      type: object
      required: [website_id, tombstoned, not_found]
      properties:
        website_id: { type: string }
        tombstoned:
          type: array
          items: { type: string }
        not_found:
          type: array
          items: { type: string }
        republish_job_id: { type: [string, "null"] }
    IngestDeleteResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/IngestDeleteResult" }
    IngestHeartbeatResult:
      type: object
      required: [next_action, backoff_seconds, server_time, verification_state]
      properties:
        next_action: { type: string, enum: [continue, pause, backfill] }
        backoff_seconds: { type: integer }
        server_time: { type: string, format: date-time }
        verification_state: { $ref: "#/components/schemas/VerificationState" }
        cursor_ack: { type: string }
    IngestHeartbeatResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/IngestHeartbeatResult" }
    IngestSchemaResult:
      type: object
      required: [website_id, connector_type, version, accepted_mappings, effective_at]
      properties:
        website_id: { type: string }
        connector_type: { type: string }
        version: { type: string }
        accepted_mappings: { type: integer }
        effective_at: { type: string, format: date-time }
    IngestSchemaResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/IngestSchemaResult" }

    # ── Knowledge ──────────────────────────────────────────────────────────────
    SearchResult:
      type: object
      required: [type, id, title, snippet, score]
      properties:
        type: { type: string, enum: [entity, fact, chunk] }
        id: { type: string }
        title: { type: string }
        snippet: { type: string }
        score: { type: number }
    SearchResultListResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/SearchResult" }
    Fact:
      type: object
      required: [id, websiteId, subject, predicate, value, confidence, language, provenanceMethod, editorialStatus, pinned, createdAt, updatedAt]
      properties:
        id: { type: string }
        websiteId: { type: string, format: uuid }
        subjectEntityId: { type: string }
        subject: { type: string }
        predicate: { type: string }
        value: {}
        unit: { type: string }
        currency: { type: string }
        display: { type: string }
        sourceUrl: { type: string }
        sourceContentId: { type: string }
        confidence: { type: number }
        importance: { type: integer }
        freshness: { type: string }
        validFrom: { type: string, format: date-time }
        validUntil: { type: string, format: date-time }
        language: { type: string }
        provenanceMethod: { type: string }
        editorialStatus: { type: string }
        pinned: { type: boolean }
        reviewedBy: { type: string }
        reviewedAt: { type: string, format: date-time }
        originalValue:
          type: object
          additionalProperties: true
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    FactListResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/Fact" }
    RelationshipEdge:
      type: object
      required: [fromEntityId, toEntityId, predicate]
      properties:
        fromEntityId: { type: string }
        toEntityId: { type: string }
        predicate: { type: string }
        confidence: { type: number }
        depth: { type: integer }
    RelationshipEdgeListResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/RelationshipEdge" }

    # ── Publishing ─────────────────────────────────────────────────────────────
    PublishedFileEntry:
      type: object
      required: [path, checksum, bytes, records]
      properties:
        path: { type: string }
        checksum: { type: string }
        bytes: { type: integer }
        records: { type: integer }
        contentType: { type: string }
        immutable: { type: boolean }
    PublishResult:
      type: object
      required: [versionUlid, versionLabel, files, cdnPath]
      properties:
        versionUlid: { type: string }
        versionLabel: { type: string }
        cdnPath: { type: string }
        files:
          type: array
          items: { $ref: "#/components/schemas/PublishedFileEntry" }
    PublishResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/PublishResult" }
    PreviewFileEntry:
      type: object
      required: [path, records, bytes]
      properties:
        path: { type: string }
        records: { type: integer }
        bytes: { type: integer }
    PreviewResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              required: [files]
              properties:
                files:
                  type: array
                  items: { $ref: "#/components/schemas/PreviewFileEntry" }
    TakedownResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                tombstoned: { type: boolean }

    # ── Billing ────────────────────────────────────────────────────────────────
    UsageEntry:
      type: object
      required: [dimension, used, periodStart]
      properties:
        dimension: { type: string }
        used: { type: number }
        periodStart: { type: string, format: date-time }
    Entitlements:
      type: object
      required: [plan, status, capabilities, limits, usage]
      properties:
        plan: { type: string, description: "e.g. free | pro | business." }
        status: { type: string, enum: [active, past_due, canceled] }
        capabilities:
          type: object
          additionalProperties: { type: boolean }
          description: Capability flags keyed by feature (e.g. llm.extraction, embeddings, citations, openapi, mcp, api.keys).
        limits:
          type: object
          additionalProperties: { type: number }
          description: Numeric limits keyed by dimension (e.g. websites.max, monthly.publishes, api.keys.max, llm.tokens).
        usage:
          type: array
          items: { $ref: "#/components/schemas/UsageEntry" }
    EntitlementsResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/Entitlements" }
    UsageResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              required: [usage]
              properties:
                usage:
                  type: array
                  items: { $ref: "#/components/schemas/UsageEntry" }

    # ── Admin ──────────────────────────────────────────────────────────────────
    AdminOrgRow:
      type: object
      required: [id, name, plan, createdAt]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        slug: { type: [string, "null"] }
        plan: { type: string }
        createdAt: { type: string, format: date-time }
    AdminOrgListResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/AdminOrgRow" }
    AdminMemberRow:
      type: object
      required: [userId, email, role, joinedAt]
      properties:
        userId: { type: string, format: uuid }
        email: { type: string, format: email }
        role: { $ref: "#/components/schemas/Role" }
        joinedAt: { type: string, format: date-time }
    AdminMemberListResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/AdminMemberRow" }
    AdminAuditLogRow:
      type: object
      required: [id, action, details, createdAt]
      properties:
        id: { type: string, format: uuid }
        organizationId: { type: [string, "null"], format: uuid }
        websiteId: { type: [string, "null"], format: uuid }
        actor: { type: [string, "null"] }
        action: { type: string }
        details:
          type: object
          additionalProperties: true
        createdAt: { type: string, format: date-time }
    AdminAuditLogListResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: array
              items: { $ref: "#/components/schemas/AdminAuditLogRow" }
    AdminOverview:
      type: object
      required: [organizations, websites, publishedNodeWebsites, verifiedWebsites, verifiedRate]
      properties:
        organizations: { type: integer }
        websites: { type: integer }
        publishedNodeWebsites: { type: integer }
        verifiedWebsites: { type: integer }
        verifiedRate: { type: number }
    AdminOverviewResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/AdminOverview" }
    ImpersonateRequest:
      type: object
      required: [targetUserId, justification]
      properties:
        targetUserId: { type: string, format: uuid }
        justification: { type: string, minLength: 10 }
    ImpersonateResult:
      type: object
      required: [accessToken, tokenType, expiresIn, impersonating]
      properties:
        accessToken: { type: string }
        tokenType: { type: string, enum: [Bearer] }
        expiresIn: { type: integer }
        impersonating:
          type: object
          required: [userId]
          properties:
            userId: { type: string, format: uuid }
    ImpersonateResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/ImpersonateResult" }

    # ── Analytics ──────────────────────────────────────────────────────────────
    RollupRow:
      type: object
      required: [metric, value, periodStart]
      properties:
        metric: { type: string }
        value: { type: number }
        periodStart: { type: string, format: date-time }
    WebsiteAnalytics:
      type: object
      required: [websiteId, available, series]
      properties:
        websiteId: { type: string, format: uuid }
        available: { type: boolean }
        series:
          type: array
          items: { $ref: "#/components/schemas/RollupRow" }
    AnalyticsResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/WebsiteAnalytics" }

    # ── Waitlist ───────────────────────────────────────────────────────────────
    WaitlistRequest:
      type: object
      additionalProperties: false
      required: [email]
      properties:
        email: { type: string, format: email, minLength: 3, maxLength: 320 }
        source: { $ref: "#/components/schemas/WaitlistSource" }
        consent: { type: boolean, default: false }
    WaitlistResponse:
      allOf:
        - $ref: "#/components/schemas/SuccessEnvelope"
        - type: object
          properties:
            data:
              type: object
              properties:
                status: { type: string, enum: [accepted] }
