openapi: 3.0.3
info:
  title: UserVane Edge API
  version: "0.1"
  description: |
    Wire contract for the UserVane edge API (`api.uservane.com`).

    Auth is load-bearing:
    - **Publishable key** (`uv_pk_...`): browser / client SDK paths. Pass as
      body `key`, query `key`, or (where noted) header. Never sufficient for
      token mint, pending-scores, or privacy.
    - **Secret key** (`uv_sk_...`): server-only. `Authorization: Bearer <sk>`
      or `X-UserVane-Key: <sk>`. Used for session-bound token mint, Langfuse
      pending-scores queue, and privacy export/erase.

    Spec derived from `apps/uservane-api/src/schemas.ts` and route handlers in
    `apps/uservane-api/src/app.ts`. Do not invent paths.
  contact:
    name: UserVane
    url: https://uservane.com
servers:
  - url: https://api.uservane.com
    description: Production
tags:
  - name: Public SDK
    description: Publishable-key routes used by browser and agent SDKs
  - name: Secret SDK
    description: Secret-key routes for customer servers only
  - name: Privacy
    description: DSAR export and erase (secret key)

paths:
  /v1/sdk/bootstrap:
    post:
      tags: [Public SDK]
      operationId: sdkBootstrap
      summary: Bootstrap surveys, suppression, and unbound show-tokens
      description: |
        Returns active survey definitions, the server-authoritative suppression
        set for the respondent, and per-survey single-use show-tokens (when
        the respondent is eligible). Caps and quiet period are decided here
        only (issue-time). Public (publishable) key.
      parameters:
        - name: key
          in: query
          schema:
            type: string
          description: Publishable key (also accepted in body)
        - name: userId
          in: query
          schema:
            type: string
          description: Respondent id (also accepted in body)
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BootstrapBody"
      responses:
        "200":
          description: Bootstrap payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BootstrapResponse"
        "400":
          description: Missing or invalid key / body
        "401":
          description: Invalid public key
        "403":
          description: Origin not allowed
        "429":
          description: Rate limit exceeded

  /v1/sdk/responses:
    post:
      tags: [Public SDK]
      operationId: sdkSubmitResponse
      summary: Submit a survey response (token-verified)
      description: |
        Ingests a rating after verifying the single-use show-token. Caps are
        never enforced at submit; a valid unused token is always accepted.
        Publishable key in body or query. Optional correlation fields
        (`sessionId`, `observationId`) must match token-attested values when
        the token was session-bound.
      parameters:
        - name: key
          in: query
          schema:
            type: string
          description: Publishable key (also accepted in body)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SdkResponseBody"
      responses:
        "201":
          description: Accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestOk"
        "400":
          description: Invalid body
        "401":
          description: Invalid key or token
        "403":
          description: Origin not allowed
        "404":
          description: Survey not found
        "409":
          description: Token already used
        "429":
          description: Rate limit exceeded

  /v1/dismissals:
    post:
      tags: [Public SDK]
      operationId: dismissSurvey
      summary: Record a dismissal (token-verified)
      description: |
        Records that the respondent dismissed the ask and adds the survey to
        suppression. Same token rules as response submit. Publishable key.
      parameters:
        - name: key
          in: query
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DismissalBody"
      responses:
        "201":
          description: Dismissal recorded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IngestOk"
        "400":
          description: Invalid body or missing public key
        "401":
          description: Invalid public key or token
        "403":
          description: Origin not allowed
        "404":
          description: Survey not found
        "429":
          description: Rate limit exceeded

  /v1/sdk/tokens:
    post:
      tags: [Secret SDK]
      operationId: mintBoundTokens
      summary: Mint session-bound show-tokens (server-only)
      description: |
        Mints v2 show-tokens bound to the provided `sessionId` (and optional
        `observationId` / `taskType`). Auth is secret key only; a publishable
        key Bearer fails. Same gating as bootstrap (suppression, quiet period,
        monthly cap, per-survey max). Empty `tokens` means gated, not an error.
        **Never call from the browser.**
      security:
        - SecretKeyBearer: []
        - SecretKeyHeader: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SdkTokensBody"
      responses:
        "200":
          description: Issued tokens (may be empty when gated)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SdkTokensResponse"
        "400":
          description: Invalid JSON body
        "401":
          description: Secret key required or invalid
        "429":
          description: Rate limit exceeded

  /v1/sdk/pending-scores:
    get:
      tags: [Secret SDK]
      operationId: listPendingScores
      summary: Poll linked scores awaiting Langfuse push
      description: |
        Tenant-scoped queue of linked responses with `score_state=pending`
        (and optionally `failed`). Serves verbatim PII for this project only.
        Secret key only. Used by `@uservane/langfuse-push`.
      security:
        - SecretKeyBearer: []
        - SecretKeyHeader: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
          description: Page size (server-capped)
        - name: includeFailed
          in: query
          schema:
            type: string
            enum: ["true"]
          description: When `true`, also return previously failed rows
        - name: cursor
          in: query
          schema:
            type: string
          description: Opaque keyset cursor from a prior `nextCursor`
      responses:
        "200":
          description: Pending score page
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PendingScoresResponse"
        "400":
          description: Invalid cursor
        "401":
          description: Secret key required or invalid
        "429":
          description: Rate limit exceeded

  /v1/sdk/pending-scores/ack:
    post:
      tags: [Secret SDK]
      operationId: ackPendingScores
      summary: Ack delivered or failed pending scores
      description: |
        Adapter reports per-id `delivered` or `failed`. Updates only this
        project's rows; foreign ids are a silent no-op. Secret key only.
      security:
        - SecretKeyBearer: []
        - SecretKeyHeader: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PendingScoresAckBody"
      responses:
        "200":
          description: Ack applied
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PendingScoresAckResponse"
        "400":
          description: Invalid ack body
        "401":
          description: Secret key required or invalid
        "429":
          description: Rate limit exceeded

  /v1/privacy/export:
    get:
      tags: [Privacy]
      operationId: privacyExport
      summary: Export responses (DSAR / admin)
      description: |
        Secret-key export of response rows. Optional filters by `respondentId`,
        `email` (imported rows; hashed server-side to `imp_`), and `surveySlug`.
      security:
        - SecretKeyBearer: []
        - SecretKeyHeader: []
      parameters:
        - name: respondentId
          in: query
          schema:
            type: string
          description: Live SDK userId or known imp_ hash
        - name: email
          in: query
          schema:
            type: string
          description: Natural identity for imported Delighted rows
        - name: surveySlug
          in: query
          schema:
            type: string
      responses:
        "200":
          description: Export payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PrivacyExportResponse"
        "400":
          description: Invalid selectors
        "401":
          description: Secret key required or invalid
        "404":
          description: Survey not found (when surveySlug set)

  /v1/privacy/erase:
    delete:
      tags: [Privacy]
      operationId: privacyErase
      summary: Erase responses (DSAR / purge)
      description: |
        Secret-key erasure. Modes:
        - respondent: `respondentId` and/or `email` (deletes responses and
          dismissals; retains hashed suppression so never-re-prompt survives)
        - survey: `surveySlug` alone (per-survey purge)
        - account: `account=true` (full project purge)
      security:
        - SecretKeyBearer: []
        - SecretKeyHeader: []
      parameters:
        - name: respondentId
          in: query
          schema:
            type: string
        - name: email
          in: query
          schema:
            type: string
        - name: surveySlug
          in: query
          schema:
            type: string
        - name: account
          in: query
          schema:
            type: string
            enum: ["true", "1"]
          description: Full account purge when set
      responses:
        "200":
          description: Erase result (mode-dependent body)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PrivacyEraseResponse"
        "400":
          description: Invalid selectors
        "401":
          description: Secret key required or invalid
        "404":
          description: Survey not found

components:
  securitySchemes:
    SecretKeyBearer:
      type: http
      scheme: bearer
      description: Project secret key (`uv_sk_...`). Server-only.
    SecretKeyHeader:
      type: apiKey
      in: header
      name: X-UserVane-Key
      description: Alternate secret key header. Server-only.

  schemas:
    BootstrapBody:
      type: object
      properties:
        key:
          type: string
          minLength: 1
          description: Publishable key
        userId:
          type: string
          minLength: 1
          nullable: true
        traits:
          type: object
          additionalProperties: true

    BootstrapResponse:
      type: object
      required: [surveys, suppression]
      properties:
        surveys:
          type: array
          items:
            $ref: "#/components/schemas/SurveyDefinition"
        suppression:
          type: array
          items:
            type: string
          description: Survey ids this respondent has answered or dismissed
        showTokens:
          type: object
          additionalProperties:
            type: string
          description: surveyId -> single-use show-token; missing means gated

    SurveyDefinition:
      type: object
      required: [id, slug, type, question, presentation, trigger]
      properties:
        id:
          type: string
        slug:
          type: string
        type:
          type: string
          enum: [nps, csat, ces, pmf]
        question:
          type: string
        followUpQuestion:
          type: string
        presentation:
          type: string
          enum: [corner, inline, banner]
        corner:
          type: string
          enum: [bottom-right, bottom-left, top-right, top-left]
        trigger:
          type: string
          enum: [auto, manual]
        delayMs:
          type: number
        samplePercent:
          type: number
        targeting:
          type: array
          items:
            type: object
            required: [trait, op]
            properties:
              trait:
                type: string
              op:
                type: string
                enum: [eq, neq, in, gt, lt, gte, lte, exists]
              value: {}
        showBadge:
          type: boolean
        endLabels:
          type: object
          properties:
            low:
              type: string
            high:
              type: string

    SdkResponseBody:
      type: object
      required: [showToken, surveyId, rating]
      properties:
        showToken:
          type: string
          minLength: 1
        surveyId:
          type: string
          minLength: 1
        rating:
          type: integer
          minimum: 0
          maximum: 10
          description: Outer bound; per-type range enforced after survey load
        text:
          type: string
          maxLength: 10000
        followUpRequested:
          type: boolean
        userId:
          type: string
          minLength: 1
        traits:
          type: object
          additionalProperties: true
        key:
          type: string
          minLength: 1
          description: Publishable key
        sessionId:
          type: string
          minLength: 1
          maxLength: 256
          description: Must match token-attested session when bound
        observationId:
          type: string
          minLength: 1
          maxLength: 256
        taskType:
          type: string
          minLength: 1
          maxLength: 128
          description: Untrusted display metadata for PM segmentation
        identityKind:
          type: string
          enum: [identified, anon-device, anon-conversation]

    DismissalBody:
      type: object
      required: [showToken, surveyId, userId]
      properties:
        showToken:
          type: string
          minLength: 1
        surveyId:
          type: string
          minLength: 1
        userId:
          type: string
          minLength: 1
        key:
          type: string
          minLength: 1

    SdkTokensBody:
      type: object
      required: [respondentId, sessionId]
      properties:
        respondentId:
          type: string
          minLength: 1
          maxLength: 256
        sessionId:
          type: string
          minLength: 1
          maxLength: 256
          description: Authoritative server session id (Langfuse / AI SDK)
        observationId:
          type: string
          minLength: 1
          maxLength: 256
        taskType:
          type: string
          minLength: 1
          maxLength: 128
        surveyId:
          type: string
          minLength: 1
          description: When set, only mint for this active survey

    SdkTokensResponse:
      type: object
      required: [tokens, suppression, issuedAt]
      properties:
        tokens:
          type: object
          additionalProperties:
            type: string
          description: surveyId -> v2 show-token bound to sessionId
        suppression:
          type: array
          items:
            type: string
        issuedAt:
          type: string
          format: date-time

    PendingScore:
      type: object
      required: [id, surveyId, at]
      properties:
        id:
          type: string
        sessionId:
          type: string
          nullable: true
        observationId:
          type: string
          nullable: true
        rating:
          type: number
          nullable: true
        text:
          type: string
          nullable: true
        taskType:
          type: string
          nullable: true
        surveyId:
          type: string
        at:
          type: string
          format: date-time

    PendingScoresResponse:
      type: object
      required: [scores]
      properties:
        scores:
          type: array
          items:
            $ref: "#/components/schemas/PendingScore"
        nextCursor:
          type: string

    PendingScoresAckBody:
      type: object
      required: [results]
      properties:
        results:
          type: array
          minItems: 1
          maxItems: 200
          items:
            type: object
            required: [id, state]
            properties:
              id:
                type: string
                minLength: 1
                maxLength: 128
              state:
                type: string
                enum: [delivered, failed]

    PendingScoresAckResponse:
      type: object
      required: [ok, updated]
      properties:
        ok:
          type: boolean
          enum: [true]
        updated:
          type: integer

    IngestOk:
      type: object
      required: [ok, id, at]
      properties:
        ok:
          type: boolean
          enum: [true]
        id:
          type: string
        at:
          type: string
          format: date-time

    PrivacyExportResponse:
      type: object
      required: [projectId, exportedAt, count, responses]
      properties:
        projectId:
          type: string
        exportedAt:
          type: string
          format: date-time
        count:
          type: integer
        responses:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
              surveyId:
                type: string
              surveyVersion:
                type: integer
              respondentId:
                type: string
              rating:
                type: number
                nullable: true
              text:
                type: string
                nullable: true
              followUpRequested:
                type: boolean
              at:
                type: string
              source:
                type: string
                enum: [measured, imported]
              traits:
                type: object
                nullable: true
                additionalProperties: true

    PrivacyEraseResponse:
      type: object
      required: [ok, mode]
      properties:
        ok:
          type: boolean
          enum: [true]
        mode:
          type: string
          enum: [respondent, survey, account]
        respondentIds:
          type: array
          items:
            type: string
        responsesDeleted:
          type: integer
        dismissalsDeleted:
          type: integer
        suppressionRetained:
          type: integer
        surveySlug:
          type: string
        note:
          type: string
