openapi: 3.1.0
info:
  title: Jovida Health Hub REST v1
  version: 1.1.0
  summary: 按类别授权读写个人健康数据
  description: |
    与 MCP 共用访问令牌与按类别授权（说明见 https://jovida.ai/developers）。
    - 读取：`health:read:<类别>`；写入：`health:write:<类别>`。旧范围 `health:read` / `health:write` 覆盖全部类别（文档原件除外）。
    - 写入端点（POST/PATCH/DELETE /v1/records）需要服务端开启开发者应用；未开启时返回 405。
    - 第三方只能修改或删除自己写入的记录。缺失值为 null，不当作零。
    - 出站 webhook 载荷只带引用（记录 id、类别、写入方），收到后用令牌读取；签名头 `Hub-Signature: t=<秒>,v1=<hex(HMAC-SHA256(secret, "t.body"))>`。
servers:
  - url: https://jovida.ai
security:
  - bearer: []
tags:
  - name: 身份与目录
  - name: 读取
  - name: 写入
paths:
  /v1/me:
    get:
      tags: [身份与目录]
      summary: 令牌对应的人员、客户端与可读写类别
      operationId: getMe
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Me' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /v1/categories:
    get:
      tags: [身份与目录]
      summary: 类别目录（含平台对应名与事件类型），每类附当前令牌的读写标记
      operationId: listCategories
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [categories]
                properties:
                  categories:
                    type: array
                    items: { $ref: '#/components/schemas/Category' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/records:
    get:
      tags: [读取]
      summary: 按类型或类别分页列出记录
      description: type 与 category 至少一项。高频类型（heart_rate、blood_glucose、calories_burned）只能按 type 查询，每次最多 24 小时。
      operationId: listRecords
      parameters:
        - { name: type, in: query, schema: { type: string, maxLength: 80 } }
        - { name: category, in: query, schema: { type: string, maxLength: 40 } }
        - { name: from, in: query, required: true, description: 含，ISO 8601 带时区, schema: { type: string, format: date-time } }
        - { name: to, in: query, required: true, description: 不含，ISO 8601 带时区, schema: { type: string, format: date-time } }
        - { $ref: '#/components/parameters/Limit50' }
        - { $ref: '#/components/parameters/Cursor' }
        - { name: view, in: query, schema: { type: string, enum: [summary, full], default: summary } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RecordPage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [写入]
      summary: 新建记录
      description: 需要该类型所属类别的写权限。餐食 payload 只给 items，营养由服务端计算。同一 Idempotency-Key 重放返回原回执；同 key 不同内容返回 409。
      operationId: createRecord
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateRecord' }
      responses:
        '201':
          description: 已写入（或重放的原回执）
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WriteReceipt' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '409': { $ref: '#/components/responses/Conflict' }
        '415': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /v1/records/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, maxLength: 300 } }
    get:
      tags: [读取]
      summary: 单条记录
      operationId: getRecord
      parameters:
        - { name: view, in: query, schema: { type: string, enum: [summary, full], default: full } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: '#/components/schemas/Record' }
                  - type: object
                    properties:
                      category: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [写入]
      summary: 按当前版本更正本应用写入的记录
      operationId: updateRecord
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateRecord' }
      responses:
        '200':
          description: 已更正
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WriteReceipt' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '409': { $ref: '#/components/responses/Conflict' }
    delete:
      tags: [写入]
      summary: 按当前版本软删除本应用写入的记录
      operationId: deleteRecord
      parameters:
        - { $ref: '#/components/parameters/IdempotencyKey' }
        - { name: version, in: query, required: true, schema: { type: integer, minimum: 1 } }
      responses:
        '200':
          description: 已删除
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WriteReceipt' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '409': { $ref: '#/components/responses/Conflict' }
  /v1/metrics/{metric}/daily:
    get:
      tags: [读取]
      summary: 逐日指标（沿用 App 的择源与日聚合）
      operationId: getDailyMetric
      parameters:
        - { name: metric, in: path, required: true, schema: { type: string } }
        - { name: from, in: query, description: 本地日期，默认 to 前 13 天, schema: { type: string, format: date } }
        - { name: to, in: query, description: 本地日期，默认今天；最多 365 天, schema: { type: string, format: date } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DailyMetric' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/export:
    get:
      tags: [读取]
      summary: 某类别全部历史的完整记录，按 cursor 翻页
      operationId: exportCategory
      parameters:
        - { name: category, in: query, required: true, schema: { type: string } }
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 200 } }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ExportPage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
webhooks:
  healthEvent:
    post:
      summary: 出站事件（record.created / record.updated / record.deleted / grant.revoked）
      description: 只投给已授权该类别读取、未撤销的连接；接收方按 id 去重，2xx 视为成功，否则按 30s、2m、10m、30m、1h、2h、6h、12h 重试。
      parameters:
        - { name: Hub-Signature, in: header, required: true, schema: { type: string, example: 't=1761130000,v1=5f…' } }
        - { name: Hub-Event-Id, in: header, required: true, schema: { type: string, format: uuid } }
        - { name: Hub-Event-Type, in: header, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEvent' }
      responses:
        '200': { description: 已接收 }
components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      description: MCP OAuth 访问令牌（hbm_…）或个人令牌（hbp_…）
  parameters:
    Limit50: { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
    Cursor: { name: cursor, in: query, description: 上一页的 nextCursor，须配合相同参数, schema: { type: string, maxLength: 12000 } }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: 8–100 位字母、数字、- 或 _；即该写入的 requestId，重试必须复用
      schema: { type: string, pattern: '^[A-Za-z0-9_-]{8,100}$' }
  responses:
    BadRequest:
      description: 参数无效（invalid_request、unknown_type、unknown_category、unsupported_category、invalid_cursor、range_too_large、invalid_date、idempotency_key_required；415 unsupported_media_type）
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Unauthorized:
      description: invalid_token
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Forbidden:
      description: insufficient_scope、source_usage_denied、forbidden_writer（只能修改本应用写入的记录）
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    NotFound:
      description: not_found、unknown_metric
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    MethodNotAllowed:
      description: method_not_allowed（未开启写入或该地址只读）
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Conflict:
      description: request_conflict（同一 Idempotency-Key 不同内容）、version_conflict（版本已变化）
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    RateLimited:
      description: rate_limited，带 Retry-After
      headers:
        Retry-After: { schema: { type: integer } }
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
    Me:
      type: object
      properties:
        personId: { type: string }
        client:
          type: object
          properties:
            id: { type: string }
            name: { type: string }
            kind: { type: string, enum: [oauth, personal] }
        scopes: { type: array, items: { type: string } }
        categories:
          type: object
          properties:
            read: { type: array, items: { type: string } }
            write: { type: array, items: { type: string } }
    Category:
      type: object
      properties:
        code: { type: string }
        title: { type: string }
        healthKit: { type: [string, 'null'] }
        healthConnect: { type: [string, 'null'] }
        eventTypes: { type: array, items: { type: string } }
        read: { type: boolean }
        write: { type: boolean }
    Record:
      type: object
      properties:
        id: { type: string }
        type: { type: string }
        at: { type: string, format: date-time }
        end: { type: [string, 'null'], format: date-time }
        version: { type: integer }
        payload: { type: object, additionalProperties: true }
        omittedFields: { type: array, items: { type: string } }
    RecordPage:
      type: object
      properties:
        category: { type: string }
        type: { type: string }
        types: { type: array, items: { type: string } }
        items: { type: array, items: { $ref: '#/components/schemas/Record' } }
        nextCursor: { type: [string, 'null'] }
        limit: { type: integer }
        excludedTypes:
          type: array
          items: { type: object, properties: { type: { type: string }, reason: { type: string } } }
        timezone: { type: string }
    ExportPage:
      type: object
      properties:
        category: { type: string }
        types: { type: array, items: { type: string } }
        from: { type: string }
        to: { type: string }
        items: { type: array, items: { $ref: '#/components/schemas/Record' } }
        nextCursor: { type: [string, 'null'] }
        limit: { type: integer }
        hint: { type: [string, 'null'] }
    DailyMetric:
      type: object
      properties:
        metric: { type: string }
        category: { type: string }
        title: { type: string }
        unit: { type: [string, 'null'] }
        timezone: { type: string }
        from: { type: string, format: date }
        to: { type: string, format: date }
        daily:
          type: array
          items:
            type: object
            properties:
              date: { type: string, format: date }
              value: { type: [number, 'null'] }
        sources: { type: array, items: {} }
    CreateRecord:
      type: object
      additionalProperties: false
      required: [type, at, payload]
      properties:
        type:
          type: string
          enum: [weight, hydration, meal, pain, symptom, rehab, appetite, alcohol, sleep_session, workout, mood, stress, caffeine, note, blood_pressure, body_fat, waist, height]
        at: { type: string, format: date-time }
        end: { type: string, format: date-time }
        payload: { type: object, additionalProperties: true, description: 各类型字段见 MCP get_capabilities(detail=full) 的 payloadSchemas }
    UpdateRecord:
      type: object
      additionalProperties: false
      required: [version, at, payload]
      properties:
        version: { type: integer, minimum: 1 }
        at: { type: string, format: date-time }
        payload: { type: object, additionalProperties: true }
    WriteReceipt:
      type: object
      required: [operationId, status]
      properties:
        operationId: { type: string, format: uuid }
        status: { type: string, enum: [complete] }
        recordId: { type: string }
        recordVersion: { type: integer }
        revisionId: { type: integer }
    WebhookEvent:
      type: object
      required: [id, type, createdAt, personId, grantId, category, recordId, writtenBy]
      properties:
        id: { type: string, format: uuid }
        type: { type: string, enum: [record.created, record.updated, record.deleted, grant.revoked] }
        createdAt: { type: string, format: date-time }
        personId: { type: string }
        grantId: { type: string }
        category: { type: [string, 'null'] }
        recordId: { type: [string, 'null'] }
        writtenBy: { type: [string, 'null'], description: 'jovida、mcp:<client_id> 或 sync:<来源>' }
