openapi: 3.1.0
info:
  title: misoto22.com API
  version: 1.0.0
  description: |
    Public API for Henry Chen's personal website.

    ## Conventions
    - **List responses** use `{ "results": [...], "next": "url|null", "previous": "url|null" }`
    - **Errors** follow [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) Problem Details format
    - **Pagination** uses `page` and `page_size` (default 20, max 100)
    - **Rate-limited endpoints** return `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` headers
  contact:
    name: Henry Chen
    url: https://misoto22.com
    email: henrycxw@gmail.com

servers:
  - url: https://misoto22.com
    description: Production
  - url: http://localhost:3000
    description: Local development

tags:
  - name: Projects
    description: Software projects and portfolio work
  - name: Blog
    description: Blog posts and categories
  - name: Photos
    description: Photography gallery
  - name: Education
    description: Education history
  - name: Experience
    description: Work experience
  - name: Stats
    description: Site analytics (public, anonymised)
  - name: Cache
    description: ISR cache revalidation (authenticated)
  - name: Contact
    description: Contact form submission
  - name: Ask
    description: AI Q&A over site content (RAG)
  - name: Search
    description: Command palette data
  - name: music
    description: Spotify now-playing and music data
  - name: System
    description: Operational endpoints (liveness probe)
  - name: Admin
    description: >-
      Server-to-server console API for misoto22-admin. Every route requires
      `Authorization: Bearer <ADMIN_API_TOKEN>` and is unpaginated (single
      operator, small datasets).

paths:
  # ── Projects ──────────────────────────────────────────
  /api/blog:
    get:
      tags: [Blog]
      summary: List blog posts
      operationId: listBlogPosts
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/page_size"
        - $ref: "#/components/parameters/locale"
        - name: category
          in: query
          schema: { type: string }
          description: Filter by category name
      responses:
        "200":
          description: Paginated blog posts
          headers:
            X-RateLimit-Limit:
              schema: { type: string }
            X-RateLimit-Remaining:
              schema: { type: string }
            X-RateLimit-Reset:
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ListEnvelope" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/blog/unlock:
    post:
      tags: [Blog]
      summary: Unlock the private blog area
      operationId: unlockBlog
      description: >-
        Verifies the global private-area password and, on success, sets an
        httpOnly signed cookie that grants read access to private posts. Rate
        limited per IP to guard against brute force.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                password: { type: string }
      responses:
        "200":
          description: Unlocked; sets the `blog_unlock` cookie
          headers:
            Set-Cookie:
              schema: { type: string }
            X-RateLimit-Limit:
              schema: { type: string }
            X-RateLimit-Remaining:
              schema: { type: string }
            X-RateLimit-Reset:
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                properties:
                  unlocked: { type: boolean }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/draft:
    get:
      tags: [Admin]
      summary: Preview draft content via admin preview token
      operationId: previewDraft
      description: >-
        Initiates a browser preview session by verifying the preview token,
        enabling Next.js Draft Mode, setting the preview scope cookie, and
        redirecting to the target post's real URL. Called directly via browser
        navigation from admin panel preview links.
      parameters:
        - name: preview
          in: query
          required: true
          schema: { type: string }
          description: Signed preview token (issued by /api/admin/preview-token POST)
      responses:
        "307":
          description: >-
            Temporary redirect to the target post's real URL with Draft Mode
            enabled and preview scope cookie set (httpOnly). The redirect URL
            is locale-aware (/zh/blog/{slug} for locale=zh, /blog/{slug} for en).
          headers:
            Set-Cookie:
              schema: { type: string }
            Location:
              schema: { type: string }
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Photos ────────────────────────────────────────────
  /api/photos:
    get:
      tags: [Photos]
      summary: List photos
      operationId: listPhotos
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/page_size"
        - $ref: "#/components/parameters/locale"
        - name: category
          in: query
          schema: { type: string }
          description: Filter by category slug
      responses:
        "200":
          description: Paginated photos
          headers:
            X-RateLimit-Limit:
              schema: { type: string }
            X-RateLimit-Remaining:
              schema: { type: string }
            X-RateLimit-Reset:
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ListEnvelope" }
        "400":
          $ref: "#/components/responses/BadRequest"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/stats:
    get:
      tags: [Stats]
      summary: Get site analytics
      operationId: getStats
      description: Durable Kioku aggregates. activeVisitors is always null; use /api/stats/active for live state.
      parameters:
        - name: days
          in: query
          schema: { type: integer, default: 30, enum: [1, 7, 30, 365] }
          description: Supported lookback window
      responses:
        "200":
          description: Aggregated site statistics
          headers:
            X-RateLimit-Limit:
              schema: { type: string }
            X-RateLimit-Remaining:
              schema: { type: string }
            X-RateLimit-Reset:
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatsResponse" }
        "429":
          $ref: "#/components/responses/RateLimited"
        "422":
          $ref: "#/components/responses/ValidationError"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/stats/active:
    get:
      tags: [Stats]
      summary: Get the live active-visitor count
      operationId: getActiveVisitors
      description: Number of visitors active in the last few minutes (polled by the live widget).
      responses:
        "200":
          description: Active visitor count
          headers:
            X-RateLimit-Limit:
              schema: { type: string }
            X-RateLimit-Remaining:
              schema: { type: string }
            X-RateLimit-Reset:
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                properties:
                  visitors: { type: [integer, "null"], minimum: 0 }
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Cache Revalidation ────────────────────────────────
  /api/revalidate:
    post:
      tags: [Cache]
      summary: Revalidate ISR cache
      operationId: revalidateCache
      description: Requires a Bearer token matching the REVALIDATION_SECRET env var.
      security:
        - bearerAuth: []
      parameters:
        - name: path
          in: query
          schema: { type: string }
          description: Specific path to revalidate (e.g. /blog)
        - name: tag
          in: query
          schema: { type: string }
          description: Cache tag to revalidate (e.g. blog-posts)
      responses:
        "200":
          description: Revalidation succeeded
          headers:
            X-RateLimit-Limit:
              schema: { type: string }
            X-RateLimit-Remaining:
              schema: { type: string }
            X-RateLimit-Reset:
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  revalidated: { type: boolean }
                  paths:
                    type: array
                    items: { type: string }
                  revalidatedAt: { type: string, format: date-time }
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Contact ───────────────────────────────────────────
  /api/contact:
    post:
      tags: [Contact]
      summary: Send a contact-form message
      operationId: sendContactMessage
      description: >-
        Relays a visitor message to the site owner via email. Rate limited to
        5 requests per 15 minutes per IP. Validation failures collect all field
        errors into the problem document's `errors` array.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, email, subject, message]
              properties:
                name: { type: string, maxLength: 100 }
                email: { type: string, format: email, maxLength: 254 }
                subject: { type: string, maxLength: 200 }
                message: { type: string, maxLength: 5000 }
      responses:
        "200":
          description: Message accepted and dispatched
          headers:
            X-RateLimit-Limit:
              schema: { type: string }
            X-RateLimit-Remaining:
              schema: { type: string }
            X-RateLimit-Reset:
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Ask ───────────────────────────────────────────────
  /api/ask:
    post:
      tags: [Ask]
      summary: Ask a question about the site's content
      operationId: askQuestion
      description: >-
        Answers a natural-language question about Henry's blog posts, projects,
        experience and education using agentic retrieval: a seeded vector
        search grounds the answer, then the model may take additional tool
        steps (reformulated searches, full-page reads, live GitHub activity)
        before answering. Rate limited to 20 questions per 10 minutes per IP,
        plus a site-wide daily cap. The response is a Server-Sent Events
        stream: a `retrieval` event first carries pipeline metadata (retrieved
        chunks with similarity scores, stage timings, model names), `step`
        events trace the agent's tool calls, `delta` events carry answer text
        fragments, a `reset` event clears any answer-in-progress when streamed
        preamble turns out to precede a tool call, a final `citations` event
        lists the sources the answer actually used, and `done` closes the
        exchange. A mid-stream failure emits an `error` event instead.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [question]
              properties:
                question: { type: string, maxLength: 500 }
                locale:
                  type: string
                  enum: [en, zh]
                  default: en
                history:
                  type: array
                  maxItems: 6
                  description: Prior turns, oldest first; capped to the last 6.
                  items:
                    type: object
                    required: [role, content]
                    properties:
                      role: { type: string, enum: [user, assistant] }
                      content: { type: string, maxLength: 2000 }
      responses:
        "200":
          description: SSE stream of `retrieval`, `step`, `delta`, `reset`, `citations`, `done` (or `error`) events
          headers:
            X-RateLimit-Limit:
              schema: { type: string }
            X-RateLimit-Remaining:
              schema: { type: string }
            X-RateLimit-Reset:
              schema: { type: string }
          content:
            text/event-stream:
              schema:
                type: string
                description: >-
                  `event: retrieval` carries `{"chunks": [{index, sourceType,
                  title, heading, url, similarity}], "corpusSize", "embedMs",
                  "retrieveMs", "embeddingModel", "embeddingDimensions",
                  "generationModel"}`; `event: step` traces one agent tool
                  call as `{"step", "tool": "search_site" | "read_page" |
                  "get_github_activity", "label", "status": "start" | "done" |
                  "error", "ms?", "addedSources?": [{index, sourceType, title,
                  heading, url, similarity}]}` (emitted at start and again on
                  completion); `event: delta` carries `{"text": "..."}`
                  fragments; `event: reset` carries `{}` and is emitted when the
                  agent streamed tool-round preamble then decided to call a tool
                  — the client clears the answer-in-progress so only the
                  post-reset answer remains; `event: citations` carries
                  `{"citations": [{index, sourceType, slug, title, heading, url}]}`;
                  `event: done` carries `{"truncated": bool}` — true when
                  generation hit the output-token budget and the answer is
                  incomplete.
        "400":
          $ref: "#/components/responses/BadRequest"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Music ─────────────────────────────────────────────
  /api/search-index/{locale}:
    get:
      tags: [Search]
      summary: Get the per-locale search index
      operationId: getSearchIndex
      description: >-
        Returns the full Cmd+K search index for a locale: one document per blog
        post (from MDX) and per portfolio item (project / experience / education,
        from Postgres). Built on demand and cached with ISR.
      parameters:
        - name: locale
          in: path
          required: true
          schema: { type: string, enum: [en, zh] }
      responses:
        "200":
          description: The search index document
          headers:
            X-RateLimit-Limit:
              schema: { type: string }
            X-RateLimit-Remaining:
              schema: { type: string }
            X-RateLimit-Reset:
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SearchIndexFile" }
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  # ── System ────────────────────────────────────────────
  /api/health:
    get:
      tags: [System]
      summary: Liveness probe
      operationId: healthCheck
      description: >-
        Dependency-free liveness probe — never touches the database, so a
        degraded DB does not mark the container unhealthy. `sha` identifies the
        deployed image (baked at build) so a deploy can confirm the new version
        is live.
      responses:
        "200":
          description: Service is up
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status: { type: string, example: ok }
                  sha: { type: string, nullable: true }

  # ── Admin ─────────────────────────────────────────────
  # The write surface is kioku's. This service is the public door to it —
  # kioku is reachable only inside the compose network — so everything below
  # /api/admin except the revalidate route is forwarded verbatim, and the
  # request and response schemas are kioku's own, published at its
  # /api/v1/openapi.json. Restating them here would only create a second copy
  # to keep in step.
  /api/admin/{path}:
    parameters:
      - name: path
        in: path
        required: true
        description: >-
          Any path under kioku's /api/v1/admin — projects, posts, photos,
          music, education, experience, blog-tags, uploads, preview-token,
          stats, rag. See kioku's own OpenAPI document for each one.
        schema: { type: string }
        example: projects/01936b7e-0000-7000-8000-000000000000
    get:
      tags: [Admin]
      summary: Forward a read to kioku's admin API
      operationId: adminProxyGet
      security:
        - bearerAuth: []
      responses:
        "200":
          $ref: "#/components/responses/AdminProxied"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"
    post:
      tags: [Admin]
      summary: Forward a write to kioku's admin API
      operationId: adminProxyPost
      security:
        - bearerAuth: []
      responses:
        "200":
          $ref: "#/components/responses/AdminProxied"
        "201":
          $ref: "#/components/responses/AdminProxied"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
    patch:
      tags: [Admin]
      summary: Forward a partial update to kioku's admin API
      operationId: adminProxyPatch
      security:
        - bearerAuth: []
      responses:
        "200":
          $ref: "#/components/responses/AdminProxied"
        "401":
          $ref: "#/components/responses/Unauthorized"
    put:
      tags: [Admin]
      summary: Forward a replacement to kioku's admin API
      operationId: adminProxyPut
      security:
        - bearerAuth: []
      responses:
        "200":
          $ref: "#/components/responses/AdminProxied"
        "401":
          $ref: "#/components/responses/Unauthorized"
    delete:
      tags: [Admin]
      summary: Forward a delete to kioku's admin API
      operationId: adminProxyDelete
      security:
        - bearerAuth: []
      responses:
        "204":
          description: Deleted
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/admin/revalidate:
    post:
      tags: [Admin]
      summary: Revalidate the whole site cache
      operationId: adminRevalidateAll
      description: >-
        Full-site refresh for the admin Settings screen: every nav path plus
        every cache tag, same idiom as /api/revalidate but authenticated with
        the admin bearer. Requires a Bearer token matching the ADMIN_API_TOKEN
        env var.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Revalidation succeeded
          headers:
            X-RateLimit-Limit:
              schema: { type: string }
            X-RateLimit-Remaining:
              schema: { type: string }
            X-RateLimit-Reset:
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                properties:
                  revalidated: { type: boolean }
                  paths:
                    type: array
                    items: { type: string }
                  tags:
                    type: array
                    items: { type: string }
                  revalidatedAt: { type: string, format: date-time }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >-
        Bearer token — REVALIDATION_SECRET for /api/revalidate,
        ADMIN_API_TOKEN for /api/admin/*

  parameters:
    page:
      name: page
      in: query
      schema: { type: integer, default: 1, minimum: 1 }
    page_size:
      name: page_size
      in: query
      schema: { type: integer, default: 20, minimum: 1, maximum: 100 }
    locale:
      name: locale
      in: query
      schema: { type: string, enum: [en, zh], default: en }

  responses:
    AdminProxied:
      description: >-
        kioku's response, relayed with its status and body unchanged. The
        schema is whichever one kioku documents for that path; an error is an
        RFC 9457 problem document with kioku's field errors intact.
      content:
        application/json:
          schema: {}
    BadRequest:
      description: Invalid parameters (RFC 9457)
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ProblemDetail" }
    Unauthorized:
      description: Missing or invalid authentication (RFC 9457)
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ProblemDetail" }
    Forbidden:
      description: Authenticated but not permitted, e.g. a request that bypassed the edge (RFC 9457)
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ProblemDetail" }
    NotFound:
      description: Resource not found (RFC 9457)
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ProblemDetail" }
    Conflict:
      description: Duplicate resource, e.g. a slug or Spotify id already in use (RFC 9457)
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ProblemDetail" }
    ValidationError:
      description: Semantically invalid parameters (RFC 9457)
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ProblemDetail" }
    ServiceUnavailable:
      description: A required dependency is not configured or unavailable (RFC 9457)
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ProblemDetail" }
    InternalError:
      description: Internal server error (RFC 9457)
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ProblemDetail" }
    RateLimited:
      description: Too many requests (RFC 9457)
      headers:
        Retry-After:
          schema: { type: string }
        X-RateLimit-Limit:
          schema: { type: string }
        X-RateLimit-Remaining:
          schema: { type: string }
        X-RateLimit-Reset:
          schema: { type: string }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ProblemDetail" }

  schemas:
    ProblemDetail:
      type: object
      description: RFC 9457 Problem Details
      required: [type, title, status, detail]
      properties:
        type: { type: string, format: uri, example: "https://misoto22.com/errors/bad-request" }
        title: { type: string, example: "Bad Request" }
        status: { type: integer, example: 400 }
        detail: { type: string, example: "Invalid pagination parameters" }
        instance: { type: string, example: "/api/blog" }
        errors:
          type: array
          items:
            type: object
            properties:
              field: { type: string }
              message: { type: string }

    ListEnvelope:
      type: object
      description: Standard list response envelope
      properties:
        results:
          type: array
          items: {}
        next: { type: string, nullable: true, description: "URL to next page, or null" }
        previous: { type: string, nullable: true, description: "URL to previous page, or null" }

    Category:
      type: object
      properties:
        id: { type: string }
        name: { type: string }

    BlogPost:
      type: object
      properties:
        id: { type: string }
        title: { type: string }
        slug: { type: string }
        content: { type: string }
        summary: { type: string }
        coverImage: { type: string }
        publishedAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        isPublished: { type: boolean }
        author:
          type: object
          properties:
            id: { type: string }
            name: { type: string }
            avatar: { type: string }
            bio: { type: string }
        category: { $ref: "#/components/schemas/Category" }
        tags:
          type: array
          items: { $ref: "#/components/schemas/Category" }

    Photo:
      type: object
      required: [orientation]
      properties:
        id: { type: string }
        src: { type: string }
        width: { type: integer }
        height: { type: integer }
        alt: { type: string }
        camera: { type: string }
        lens: { type: string }
        focalLength: { type: string }
        aperture: { type: string }
        shutterSpeed: { type: string }
        iso: { type: string }
        title: { type: string }
        place: { type: string }
        area: { type: string }
        lat: { type: number }
        lon: { type: number }
        year: { type: integer }
        takenAt: { type: string, format: date-time }
        orientation: { type: string, enum: [landscape, portrait, square] }

    PhotoCategory:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        slug: { type: string }

    Project:
      type: object
      properties:
        title: { type: string }
        description: { type: string }
        link: { type: string, description: Source code URL }
        deploy: { type: string, description: Live deployment URL }
        technologies:
          type: array
          items: { type: string }
        image: { type: string }
        category: { type: string }
        slug: { type: string }
        order: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    Education:
      type: object
      properties:
        degree: { type: string }
        school: { type: string }
        schoolLink: { type: string }
        location: { type: string }
        period: { type: string }
        description:
          type: array
          items: { type: string }
        courses:
          type: array
          items: { type: string }
        logo: { type: string }
        order: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    Experience:
      type: object
      properties:
        title: { type: string }
        company: { type: string }
        companyLink: { type: string }
        location: { type: string }
        period: { type: string }
        description:
          type: array
          items: { type: string }
        technologies:
          type: array
          items: { type: string }
        logo: { type: string }
        order: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    StatsResponse:
      type: object
      required: [totalViews, uniqueVisitors, visits, bounceRate, avgDuration, activeVisitors, trends, dailyViews, topPages, topReferrers, browsers, devices, countries, os, events, days, updatedAt]
      properties:
        totalViews: { type: integer }
        uniqueVisitors: { type: integer }
        visits: { type: integer }
        bounceRate: { type: integer }
        avgDuration: { type: integer }
        activeVisitors: { type: "null" }
        trends:
          type: object
          properties:
            views: { type: [integer, "null"] }
            visitors: { type: [integer, "null"] }
            bounceRate: { type: [integer, "null"] }
            avgDuration: { type: [integer, "null"] }
        dailyViews:
          type: array
          items:
            type: object
            properties:
              date: { type: string, format: date }
              views: { type: integer }
              visitors: { type: integer }
        topPages:
          type: array
          items:
            type: object
            properties:
              path: { type: string }
              views: { type: integer }
        topReferrers:
          type: array
          items:
            type: object
            properties:
              referrer: { type: string }
              views: { type: integer }
        browsers:
          type: array
          items:
            type: object
            properties:
              browser: { type: string }
              views: { type: integer }
        devices:
          type: array
          items:
            type: object
            properties:
              device: { type: string }
              views: { type: integer }
        countries:
          type: array
          items:
            type: object
            properties:
              country: { type: string }
              views: { type: integer }
        os:
          type: array
          items:
            type: object
            properties:
              os: { type: string }
              views: { type: integer }
        events:
          type: array
          items:
            type: object
            properties:
              event: { type: string }
              count: { type: integer }
        days: { type: integer }
        updatedAt: { type: [string, "null"], format: date-time }

    SearchDoc:
      type: object
      required: [id, type, title, href]
      properties:
        id: { type: string }
        type: { type: string, enum: [blog, project, experience, education] }
        title: { type: string }
        summary: { type: string }
        body: { type: string }
        tags: { type: string }
        category: { type: string }
        href: { type: string }
        date: { type: string }
    SearchIndexFile:
      type: object
      required: [locale, generatedAt, docs]
      properties:
        locale: { type: string, enum: [en, zh] }
        generatedAt: { type: string, format: date-time }
        docs:
          type: array
          items: { $ref: "#/components/schemas/SearchDoc" }

    # ── Admin DTOs (shared with the misoto22-admin console) ──
    PostTranslation:
      type: object
      description: Per-locale post content; fields may be null on a partially translated locale
      properties:
        title: { type: [string, "null"] }
        summary: { type: [string, "null"] }
        body: { type: [string, "null"] }

    TranslationsPost:
      type: object
      description: Post content keyed by locale (`en` is canonical)
      properties:
        en: { $ref: "#/components/schemas/PostTranslation" }
        zh: { $ref: "#/components/schemas/PostTranslation" }

    Asset:
      type: object
      description: >-
        Metadata for a binary stored in R2. `extractedText` (documents) and
        `caption` (images) are the retrieval text that makes the asset findable
        through RAG.
      properties:
        id: { type: string }
        r2Key: { type: string }
        url: { type: string, format: uri }
        sha256: { type: string, description: Content hash — unique, so re-uploading the same bytes returns the existing row. }
        mime: { type: string }
        bytes: { type: integer }
        kind: { type: string, enum: [image, pdf, document, other] }
        visibility: { type: string, enum: [public, unlisted, private] }
        title: { type: [string, "null"] }
        caption: { type: [string, "null"] }
        extractedText: { type: [string, "null"] }
        exif:
          type: [object, "null"]
          properties:
            takenAt: { type: [string, "null"], format: date-time }
            camera: { type: [string, "null"] }
            lens: { type: [string, "null"] }
            lat: { type: [number, "null"] }
            lon: { type: [number, "null"] }
            width: { type: [integer, "null"] }
            height: { type: [integer, "null"] }
        width: { type: [integer, "null"] }
        height: { type: [integer, "null"] }
        createdAt: { type: [string, "null"], format: date-time }
        updatedAt: { type: [string, "null"], format: date-time }
    JsonRpcMessage:
      type: object
      required: [jsonrpc, method]
      description: A JSON-RPC 2.0 request or notification. Notifications omit `id` and are answered with 202.
      properties:
        jsonrpc: { type: string, enum: ['2.0'] }
        id: { type: [string, integer] }
        method: { type: string, example: tools/call }
        params: { type: object }
    Entry:
      type: object
      description: A knowledge-base capture (bigserial ids travel as strings)
      properties:
        id: { type: string }
        kind: { type: string, enum: [note, journal, bookmark, til, snippet] }
        slug: { type: [string, "null"] }
        url: { type: [string, "null"] }
        tags: { type: array, items: { type: string } }
        sourceLocale: { type: string, description: Locale the entry was written in; other locales are derived. }
        visibility:
          type: string
          enum: [public, unlisted, private]
        createdAt: { type: [string, "null"], format: date-time }
        updatedAt: { type: [string, "null"], format: date-time }
        translations:
          type: object
          description: Per-locale text, keyed by locale code.
          additionalProperties:
            type: object
            properties:
              title: { type: [string, "null"] }
              body: { type: [string, "null"], description: Markdown. }
              updatedAt: { type: [string, "null"], format: date-time }
        translationStatus:
          type: object
          description: >-
            Freshness of each non-source locale against the source text:
            `fresh`, `stale` (the source was edited afterwards) or `missing`.
          additionalProperties: { type: string, enum: [fresh, stale, missing] }
    EntryInput:
      type: object
      required: [kind, translations]
      properties:
        kind: { type: string, enum: [note, journal, bookmark, til, snippet] }
        slug: { type: [string, "null"], description: Optional kebab-case slug; unique when present. }
        url: { type: [string, "null"], description: Target for `bookmark` entries. }
        tags: { type: array, items: { type: string } }
        sourceLocale: { type: string, default: en }
        visibility:
          type: string
          enum: [public, unlisted, private]
          default: private
        translations:
          type: object
          description: Per-locale text keyed by locale code; a locale mapped to null deletes that translation.
          additionalProperties:
            type: [object, "null"]
            properties:
              title: { type: [string, "null"] }
              body: { type: [string, "null"] }
    AdminPost:
      type: object
      description: Admin view of a blog post (bigserial ids travel as strings)
      properties:
        id: { type: string }
        slug: { type: string }
        coverImage: { type: [string, "null"] }
        category: { type: [string, "null"] }
        subcategory: { type: [string, "null"] }
        tags:
          type: array
          items: { type: string }
        authorName: { type: [string, "null"] }
        featured: { type: boolean }
        visibility:
          type: string
          enum: [public, unlisted, private]
          description: Row-level access control. Only `public` rows appear on the site, in list APIs, the sitemap, the search index and RAG answers; `unlisted` is reachable by direct link only; `private` needs the unlock cookie or an authenticated caller.
        isPrivate: { type: boolean, description: "Derived from `visibility` (deprecated — read `visibility`)." }
        isPublished: { type: boolean }
        publishedAt: { type: [string, "null"], format: date-time }
        updatedAt: { type: [string, "null"], format: date-time }
        createdAt: { type: [string, "null"], format: date-time }
        translations: { $ref: "#/components/schemas/TranslationsPost" }

    AdminPostInput:
      type: object
      required: [slug, translations]
      properties:
        slug: { type: string, description: Kebab-case URL slug }
        coverImage: { type: [string, "null"] }
        category: { type: [string, "null"] }
        subcategory: { type: [string, "null"] }
        tags:
          type: array
          items: { type: string }
        authorName: { type: [string, "null"] }
        featured: { type: boolean, default: false }
        visibility:
          type: string
          enum: [public, unlisted, private]
          default: private
          description: Row-level access control. Omitting it (and `isPrivate`) creates a private row — writes fail closed.
        isPrivate: { type: boolean, description: "Deprecated alias — sets `visibility` to private/public when `visibility` is absent." }
        isPublished: { type: boolean, default: false }
        publishedAt:
          type: [string, "null"]
          format: date-time
          description: >-
            Omit to keep the stored date; publishing without one stamps "now"
            once.
        translations:
          type: object
          required: [en]
          properties:
            en:
              type: object
              required: [title, body]
              properties:
                title: { type: string }
                summary: { type: [string, "null"] }
                body: { type: string }
            zh:
              type: [object, "null"]
              description: >-
                `null` deletes the zh translation; omitted leaves it untouched.
              properties:
                title: { type: [string, "null"] }
                summary: { type: [string, "null"] }
                body: { type: [string, "null"] }

    RevisionMeta:
      type: object
      description: Snapshot metadata for one post revision
      properties:
        id: { type: string }
        editedAt: { type: string, format: date-time }
        editedBy: { type: [string, "null"] }

    ProjectTranslation:
      type: object
      properties:
        title: { type: [string, "null"] }
        description: { type: [string, "null"] }

    TranslationsProject:
      type: object
      description: Project content keyed by locale (`en` is canonical)
      properties:
        en: { $ref: "#/components/schemas/ProjectTranslation" }
        zh: { $ref: "#/components/schemas/ProjectTranslation" }

    AdminProject:
      type: object
      description: Admin view of a project (bigserial ids travel as strings)
      properties:
        id: { type: string }
        slug: { type: [string, "null"], description: Only projects with a detail page carry a slug }
        link: { type: string, description: Source code URL }
        deploy: { type: [string, "null"], description: Live deployment URL }
        technologies:
          type: array
          items: { type: string }
        imagePath: { type: string }
        category: { type: string }
        order: { type: integer }
        visibility:
          type: string
          enum: [public, unlisted, private]
          description: Row-level access control. Only `public` rows appear on the site, in list APIs, the sitemap, the search index and RAG answers; `unlisted` is reachable by direct link only; `private` needs the unlock cookie or an authenticated caller.
        isPrivate:
          type: boolean
          description: >-
            NOT access control: the SOURCE REPOSITORY is private. Drives the
            "Private repository" badge and suppresses codeRepository in JSON-LD.
            Independent of `visibility` — such a project is still listed.
        createdAt: { type: [string, "null"], format: date-time }
        updatedAt: { type: [string, "null"], format: date-time }
        translations: { $ref: "#/components/schemas/TranslationsProject" }

    AdminProjectInput:
      type: object
      required: [link, imagePath, category, translations]
      properties:
        slug: { type: [string, "null"], description: Optional kebab-case slug }
        link: { type: string }
        deploy: { type: [string, "null"] }
        technologies:
          type: array
          items: { type: string }
        imagePath: { type: string }
        category: { type: string }
        order:
          type: integer
          description: Omit to keep the current display position
        visibility:
          type: string
          enum: [public, unlisted, private]
          default: public
          description: >-
            Row-level access control. Projects are the one entity that defaults
            to public: a portfolio item is authored deliberately through this
            authenticated form, not captured. On PATCH, omitting it keeps the
            current level.
        isPrivate:
          type: boolean
          description: >-
            NOT access control — marks the source repository private. Never
            changes `visibility`.
        translations:
          type: object
          required: [en]
          properties:
            en:
              type: object
              required: [title, description]
              properties:
                title: { type: string }
                description: { type: string }
            zh:
              type: [object, "null"]
              description: >-
                `null` deletes the zh translation; omitted leaves it untouched.
              properties:
                title: { type: [string, "null"] }
                description: { type: [string, "null"] }

    PhotoTranslation:
      type: object
      properties:
        alt: { type: [string, "null"] }
        title: { type: [string, "null"] }
        place: { type: [string, "null"] }
        area: { type: [string, "null"] }

    TranslationsPhoto:
      type: object
      description: Photo captions keyed by locale (`en` is canonical)
      properties:
        en: { $ref: "#/components/schemas/PhotoTranslation" }
        zh: { $ref: "#/components/schemas/PhotoTranslation" }

    AdminPhoto:
      type: object
      description: Admin view of a photo (bigserial ids travel as strings)
      properties:
        id: { type: string }
        src: { type: string }
        width: { type: integer }
        height: { type: integer }
        order: { type: integer }
        takenAt: { type: [string, "null"], format: date-time }
        focalLength: { type: [string, "null"] }
        aperture: { type: [string, "null"] }
        shutterSpeed: { type: [string, "null"] }
        iso: { type: [string, "null"] }
        categoryId: { type: [string, "null"], description: Photo-category UUID }
        lat: { type: [number, "null"] }
        lon: { type: [number, "null"] }
        camera: { type: [string, "null"], description: Resolved camera display name }
        lens: { type: [string, "null"], description: Resolved lens display name }
        createdAt: { type: [string, "null"], format: date-time }
        updatedAt: { type: [string, "null"], format: date-time }
        translations: { $ref: "#/components/schemas/TranslationsPhoto" }

    AdminPhotoInput:
      type: object
      required: [src, width, height, translations]
      properties:
        src: { type: string, description: Absolute https:// image URL }
        width: { type: integer, minimum: 1 }
        height: { type: integer, minimum: 1 }
        order:
          type: integer
          description: Omit to keep the current gallery position
        takenAt: { type: [string, "null"], format: date-time }
        focalLength: { type: [string, "null"] }
        aperture: { type: [string, "null"] }
        shutterSpeed: { type: [string, "null"] }
        iso: { type: [string, "null"] }
        categoryId: { type: [string, "null"], format: uuid }
        lat: { type: [number, "null"] }
        lon: { type: [number, "null"] }
        camera:
          type: [string, "null"]
          description: >-
            Gear display name — upserted into the cameras reference table;
            `null` clears, omitted leaves unchanged.
        lens:
          type: [string, "null"]
          description: >-
            Gear display name — upserted into the lenses reference table;
            `null` clears, omitted leaves unchanged.
        translations:
          type: object
          required: [en]
          properties:
            en:
              type: object
              required: [alt]
              properties:
                alt: { type: string }
                title: { type: [string, "null"] }
                place: { type: [string, "null"] }
                area: { type: [string, "null"] }
            zh:
              type: [object, "null"]
              description: >-
                `null` deletes the zh translation; omitted leaves it untouched.
              properties:
                alt: { type: [string, "null"] }
                title: { type: [string, "null"] }
                place: { type: [string, "null"] }
                area: { type: [string, "null"] }

    PhotoCategoryTranslation:
      type: object
      properties:
        name: { type: [string, "null"] }

    TranslationsPhotoCategory:
      type: object
      description: Category names keyed by locale (`en` is canonical)
      properties:
        en: { $ref: "#/components/schemas/PhotoCategoryTranslation" }
        zh: { $ref: "#/components/schemas/PhotoCategoryTranslation" }

    AdminPhotoCategory:
      type: object
      description: Admin view of a photo category (UUID ids)
      properties:
        id: { type: string, format: uuid }
        slug: { type: string }
        sortOrder: { type: integer }
        name: { type: string, description: Base canonical English name }
        translations: { $ref: "#/components/schemas/TranslationsPhotoCategory" }

    AdminPhotoCategoryInput:
      type: object
      required: [slug, translations]
      properties:
        slug: { type: string, description: Kebab-case URL slug }
        sortOrder:
          type: integer
          description: Omit to keep the current position
        translations:
          type: object
          required: [en]
          properties:
            en:
              type: object
              required: [name]
              properties:
                name: { type: string }
            zh:
              type: [object, "null"]
              description: >-
                `null` deletes the zh translation; omitted leaves it untouched.
              properties:
                name: { type: [string, "null"] }

    EducationTranslation:
      type: object
      properties:
        degree: { type: [string, "null"] }
        description:
          type: [array, "null"]
          items: { type: string }
        courses:
          type: [array, "null"]
          items: { type: string }

    TranslationsEducation:
      type: object
      description: Education content keyed by locale (`en` is canonical)
      properties:
        en: { $ref: "#/components/schemas/EducationTranslation" }
        zh: { $ref: "#/components/schemas/EducationTranslation" }

    AdminEducation:
      type: object
      description: Admin view of an education entry (bigserial ids travel as strings)
      properties:
        id: { type: string }
        school: { type: string }
        schoolLink: { type: [string, "null"] }
        location: { type: string }
        period: { type: string }
        logo: { type: string }
        order: { type: integer }
        createdAt: { type: [string, "null"], format: date-time }
        updatedAt: { type: [string, "null"], format: date-time }
        translations: { $ref: "#/components/schemas/TranslationsEducation" }

    AdminEducationInput:
      type: object
      required: [school, location, period, logo, translations]
      properties:
        school: { type: string }
        schoolLink: { type: [string, "null"] }
        location: { type: string }
        period: { type: string }
        logo: { type: string }
        order:
          type: integer
          description: Omit to keep the current display position
        translations:
          type: object
          required: [en]
          properties:
            en:
              type: object
              required: [degree, description, courses]
              properties:
                degree: { type: string }
                description:
                  type: array
                  items: { type: string }
                courses:
                  type: array
                  items: { type: string }
            zh:
              type: [object, "null"]
              description: >-
                `null` deletes the zh translation; omitted leaves it untouched.
              properties:
                degree: { type: [string, "null"] }
                description:
                  type: [array, "null"]
                  items: { type: string }
                courses:
                  type: [array, "null"]
                  items: { type: string }

    ExperienceTranslation:
      type: object
      properties:
        title: { type: [string, "null"] }
        description:
          type: [array, "null"]
          items: { type: string }

    TranslationsExperience:
      type: object
      description: Experience content keyed by locale (`en` is canonical)
      properties:
        en: { $ref: "#/components/schemas/ExperienceTranslation" }
        zh: { $ref: "#/components/schemas/ExperienceTranslation" }

    AdminExperience:
      type: object
      description: Admin view of an experience entry (bigserial ids travel as strings)
      properties:
        id: { type: string }
        company: { type: string }
        companyLink: { type: [string, "null"] }
        location: { type: string }
        period: { type: string }
        technologies:
          type: array
          items: { type: string }
        logo: { type: string }
        order: { type: integer }
        createdAt: { type: [string, "null"], format: date-time }
        updatedAt: { type: [string, "null"], format: date-time }
        translations: { $ref: "#/components/schemas/TranslationsExperience" }

    AdminExperienceInput:
      type: object
      required: [company, location, period, logo, translations]
      properties:
        company: { type: string }
        companyLink: { type: [string, "null"] }
        location: { type: string }
        period: { type: string }
        technologies:
          type: array
          items: { type: string }
        logo: { type: string }
        order:
          type: integer
          description: Omit to keep the current display position
        translations:
          type: object
          required: [en]
          properties:
            en:
              type: object
              required: [title, description]
              properties:
                title: { type: string }
                description:
                  type: array
                  items: { type: string }
            zh:
              type: [object, "null"]
              description: >-
                `null` deletes the zh translation; omitted leaves it untouched.
              properties:
                title: { type: [string, "null"] }
                description:
                  type: [array, "null"]
                  items: { type: string }

    MusicTranslation:
      type: object
      properties:
        note: { type: [string, "null"] }
        tag: { type: [string, "null"], description: Playlist-only label }

    TranslationsMusic:
      type: object
      description: Favorite notes keyed by locale
      properties:
        en: { $ref: "#/components/schemas/MusicTranslation" }
        zh: { $ref: "#/components/schemas/MusicTranslation" }

    AdminMusicFavorite:
      type: object
      description: Admin view of a music favorite (bigserial ids travel as strings)
      properties:
        id: { type: string }
        kind: { type: string, enum: [artist, playlist, track] }
        spotifyId: { type: string }
        sortOrder: { type: integer }
        createdAt: { type: [string, "null"], format: date-time }
        translations: { $ref: "#/components/schemas/TranslationsMusic" }

    AdminMusicInput:
      type: object
      required: [kind, spotifyId]
      properties:
        kind: { type: string, enum: [artist, playlist, track] }
        spotifyId: { type: string, description: Spotify id (letters and digits) }
        sortOrder:
          type: integer
          description: Omit to keep the current position
        translations:
          type: [object, "null"]
          description: >-
            Optional per-locale notes — unlike content DTOs, `en` is optional
            here. Per locale, `null` deletes the row and omitted leaves it
            untouched. `tag` is playlist-only.
          properties:
            en:
              type: [object, "null"]
              properties:
                note: { type: [string, "null"] }
                tag: { type: [string, "null"] }
            zh:
              type: [object, "null"]
              properties:
                note: { type: [string, "null"] }
                tag: { type: [string, "null"] }

    SpotifyPreview:
      type: object
      description: Metadata echo for the admin add-by-URL flow
      properties:
        kind: { type: string, enum: [artist, playlist, track] }
        spotifyId: { type: string }
        name: { type: string }
        subtitle: { type: string, description: Artists for tracks and owner for playlists; empty for artists }
        imageUrl: { type: string, description: Cover art URL (may be empty) }

    AdminStats:
      type: object
      description: Dashboard aggregate for the admin home screen
      properties:
        counts:
          type: object
          properties:
            projects: { type: integer }
            photos: { type: integer }
            posts: { type: integer }
            experience: { type: integer }
            education: { type: integer }
            music: { type: integer }
        usageToday:
          type: object
          properties:
            ask: { type: integer }
            contact: { type: integer }
        attention:
          type: array
          description: Records failing the content-quality audit; one item per failed rule
          items:
            type: object
            properties:
              screen: { type: string, enum: [projects, photography, blog, experience, education] }
              type: { type: string, enum: [Project, Photo, Post, Role, Education] }
              id: { type: string }
              title: { type: string }
              issue: { type: string }
              tone: { type: string, enum: [danger, warning, muted] }

    AdminRagStats:
      type: object
      description: RAG index health and 14-day usage series
      properties:
        total: { type: integer, description: Total indexed chunks }
        lastIndexedAt: { type: [string, "null"], format: date-time }
        model: { type: string, description: Embedding model name }
        dimensions: { type: integer, description: Embedding dimensions }
        sources:
          type: array
          items:
            type: object
            properties:
              sourceType: { type: string }
              en: { type: integer }
              zh: { type: integer }
        usage:
          type: array
          description: Dense 14-day series ending today (UTC)
          items:
            type: object
            properties:
              day: { type: string, format: date }
              ask: { type: integer }
              contact: { type: integer }
        usageToday:
          type: object
          properties:
            ask: { type: integer }
            contact: { type: integer }

    ReindexStatus:
      type: object
      description: In-process reindex job status (single long-lived container)
      properties:
        state: { type: string, enum: [idle, running, done, error] }
        startedAt: { type: [string, "null"], format: date-time }
        finishedAt: { type: [string, "null"], format: date-time }
        chunks: { type: integer, description: Chunks written by the last completed run }
        progress: { type: integer, minimum: 0, maximum: 100 }
        error: { type: [string, "null"] }

    UploadResult:
      type: object
      description: Stored R2 object
      properties:
        url: { type: string, description: Public URL on the images origin }
        key: { type: string, description: Object key inside the bucket }
        width: { type: [integer, "null"], description: Null when dimensions could not be parsed }
        height: { type: [integer, "null"], description: Null when dimensions could not be parsed }
        size: { type: integer, description: Size in bytes }
        contentType: { type: string }
        asset:
          description: The created (or already existing) asset row; null when the DB is unavailable.
          oneOf:
            - $ref: '#/components/schemas/Asset'
            - type: 'null'

    ReorderRequest:
      type: object
      required: [ids]
      properties:
        ids:
          type: array
          minItems: 1
          items: { type: string }
          description: >-
            Full id list in display order — the array index becomes the
            position. Ids left out keep their old slot; unknown ids are
            no-ops.
