openapi: 3.1.0

info:
  title: mydoc24 Marketing API
  version: 1.0.0
  summary: REST API for Solar MD's product/marketing site at mydoc24.org.
  description: |
    Programmatic surface for uploading articles + updating the editable
    section list (the tabs on the landing) without going through the
    browser admin at `/admin`. Designed for content-sync scripts and
    LLM agents that author Markdown.

    ## Authentication

    Every request must include a Bearer token:

        Authorization: Bearer mdoc_<32 base62 chars>

    Tokens are minted from `/admin/tokens/new` and revealed exactly
    once — the DB stores only the SHA-256 hash. Tokens never expire
    automatically; revoke at `/admin/tokens` to invalidate.

    A signed-in browser session is also accepted on these endpoints
    (so humans can hit them for ad-hoc testing), but agents should
    always use Bearer tokens.

    ## Article body format

    Articles are authored in Markdown. mydoc24 converts the markdown
    to sanitised HTML on save and stores both — the source for
    round-trip editing, the HTML for rendering.

    Supported Markdown features (via marked.js):
    - GFM tables, autolinks, fenced code blocks, strikethrough
    - Standard headings (`#`–`######`), lists, blockquotes, images
    - YouTube + Vimeo `<iframe>` embeds (allow-listed in the sanitiser)

    HTML tags outside the safelist are stripped silently. The
    sanitiser allow-list is in `apps/mydoc24/lib/sanitise.ts`.

    ## Section list

    `mdoc_section` controls the landing's tabs. Each section has:
    - `slug` — matches an `mdoc_article.slug`. If no article exists
      at that slug yet, the tab renders a "Coming soon" placeholder.
    - `label` — visible tab text.
    - `kicker` — small editorial numeral ("01"/"02"/...).
    - `position` — order; the array order in PUT requests is canonical.

    The PUT replaces the WHOLE list atomically — there's no diff /
    partial-update endpoint. Build the desired list client-side, send
    it, done.

  contact:
    name: Solar MD
  license:
    name: Internal — Solar MD
    identifier: LicenseRef-internal

servers:
  - url: https://mydoc24.org
    description: Production
  - url: http://localhost:3001
    description: Local dev (mydoc24's Next.js port)

security:
  - bearerAuth: []

tags:
  - name: articles
    description: Create, read, update, delete articles.
  - name: sections
    description: Manage the landing's tab list.

paths:
  /api/articles:
    get:
      tags: [articles]
      summary: List articles (metadata only)
      description: Returns every article's metadata — no bodies. Use `GET /api/articles/{slug}` for the body.
      responses:
        '200':
          description: Article list
          content:
            application/json:
              schema:
                type: object
                properties:
                  articles:
                    type: array
                    items: { $ref: '#/components/schemas/ArticleSummary' }
        '401': { $ref: '#/components/responses/Unauthorized' }

    post:
      tags: [articles]
      summary: Upsert an article by slug
      description: |
        Idempotent. If an article with `slug` already exists, replaces
        its title + body + status. Otherwise creates a new row.
        Markdown is sanitised on save.

        Re-running the same request converges on the supplied state —
        useful for content-sync scripts that run on a schedule.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ArticleUpsert' }
      responses:
        '200':
          description: Existing article updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ArticleUpsertResult' }
        '201':
          description: New article created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ArticleUpsertResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/articles/{slug}:
    parameters:
      - in: path
        name: slug
        required: true
        schema:
          type: string
          pattern: '^[a-z0-9-]+$'
        description: Article slug (lowercase letters, digits, hyphens).
        example: about

    get:
      tags: [articles]
      summary: Get a single article with body
      responses:
        '200':
          description: Article including bodyHtml + bodyMarkdown
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ArticleFull' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

    put:
      tags: [articles]
      summary: Partial update (must exist)
      description: |
        Updates any subset of `title` / `markdown` / `status` /
        `keywords`. 404s if no article with this slug exists — use
        `POST /api/articles` for upsert semantics.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ArticleUpdate' }
      responses:
        '200':
          description: Updated. Echoes which fields changed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  slug: { type: string }
                  updated:
                    type: array
                    items: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

    delete:
      tags: [articles]
      summary: Hard delete
      description: |
        Removes the row permanently. Sections referencing this slug
        keep their entry in `mdoc_section` but render a "Coming soon"
        placeholder on the landing.
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  slug: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/sections:
    get:
      tags: [sections]
      summary: List the landing's tab order
      responses:
        '200':
          description: Sections in display order
          content:
            application/json:
              schema:
                type: object
                properties:
                  sections:
                    type: array
                    items: { $ref: '#/components/schemas/Section' }
        '401': { $ref: '#/components/responses/Unauthorized' }

    put:
      tags: [sections]
      summary: Atomic replace of the section list
      description: |
        Deletes every existing `mdoc_section` row, then inserts the
        supplied list. No diff / partial support — send the FULL
        desired state every time. Kicker defaults to the 1-indexed
        position padded to 2 digits ("01", "02", ...).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sections]
              properties:
                sections:
                  type: array
                  items: { $ref: '#/components/schemas/SectionUpsert' }
      responses:
        '200':
          description: Sections replaced
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  count: { type: integer }
                  sections:
                    type: array
                    items: { $ref: '#/components/schemas/Section' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: mdoc_<32 base62 chars>
      description: |
        Mint a token at `/admin/tokens/new`. Format: `mdoc_` + 32
        base62 chars. Send as `Authorization: Bearer mdoc_...`.

  responses:
    BadRequest:
      description: Malformed request body / params
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing / invalid / revoked Bearer token (and no session cookie)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: No article with this slug
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      required: [error, message]
      properties:
        error:   { type: string, description: 'Stable machine code (e.g. "invalid_slug").' }
        message: { type: string, description: 'Human-readable detail.' }

    ArticleSummary:
      type: object
      required: [id, slug, title, status, createdAt, updatedAt]
      properties:
        id:        { type: integer, format: int64 }
        slug:      { type: string,  pattern: '^[a-z0-9-]+$' }
        title:     { type: string }
        status:    { $ref: '#/components/schemas/ArticleStatus' }
        keywords:  { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    ArticleFull:
      allOf:
        - $ref: '#/components/schemas/ArticleSummary'
        - type: object
          required: [bodyHtml]
          properties:
            bodyHtml:     { type: string, description: 'Sanitised HTML — safe to render in a Shadow DOM or trusted context.' }
            bodyMarkdown: { type: string, nullable: true, description: 'Round-trippable Markdown source.' }
            publishedAt:  { type: string, format: date-time, nullable: true }

    ArticleStatus:
      type: string
      enum: [DRAFT, PUBLISHED, ARCHIVED]
      default: DRAFT
      description: |
        Only PUBLISHED articles render on the public landing.
        DRAFT and ARCHIVED hide from `/` but stay in `/admin`.

    ArticleUpsert:
      type: object
      required: [title, markdown]
      properties:
        slug:
          type: string
          pattern: '^[a-z0-9-]+$'
          description: |
            Lowercase letters, digits, hyphens. Defaults to the
            slugified title when omitted.
          example: about
        title:
          type: string
          description: 1–255 chars.
          example: About myDoc24
        markdown:
          type: string
          description: |
            Article body in Markdown. Sanitised + converted to HTML
            on save. Empty string is allowed.
          example: |
            # About

            Solar MD's product page lives here.
        status:
          $ref: '#/components/schemas/ArticleStatus'
        keywords:
          type: string
          nullable: true
          description: Optional comma-separated tag list.

    ArticleUpdate:
      type: object
      minProperties: 1
      properties:
        title:    { type: string }
        markdown: { type: string }
        status:   { $ref: '#/components/schemas/ArticleStatus' }
        keywords: { type: string, nullable: true }

    ArticleUpsertResult:
      type: object
      required: [id, slug, title, status, created]
      properties:
        id:      { type: integer, format: int64 }
        slug:    { type: string }
        title:   { type: string }
        status:  { $ref: '#/components/schemas/ArticleStatus' }
        created:
          type: boolean
          description: |
            True if this call inserted a new row; false if it updated an
            existing row.

    Section:
      type: object
      required: [id, slug, label, kicker, position]
      properties:
        id:       { type: integer, format: int64 }
        slug:     { type: string, pattern: '^[a-z0-9-]+$' }
        label:    { type: string }
        kicker:   { type: string, description: 'Editorial numeral, e.g. "01"' }
        position: { type: integer, minimum: 1 }

    SectionUpsert:
      type: object
      required: [slug, label]
      properties:
        slug:     { type: string, pattern: '^[a-z0-9-]+$' }
        label:    { type: string, description: '1–64 chars' }
        kicker:   { type: string, description: 'Defaults to padded 1-indexed position ("01", "02", …)' }
        position: { type: integer, minimum: 1, description: 'Defaults to array order' }
