openapi: "3.1.0"

info:
  title: Twofold
  version: "0.4.0"
  description: |
    One document, two views. Twofold is a self-hosted markdown share service that serves
    styled HTML to humans and raw markdown to agents from the same document.

    ## Authentication

    Write operations (create, update, delete, list) require a Bearer token in the
    Authorization header. Read operations on the human-facing routes are public unless
    the document is password-protected.

    ```
    Authorization: Bearer <token>
    ```

    ## Frontmatter

    Documents may include a YAML frontmatter block at the start:

    ```markdown
    ---
    title: My Document
    slug: custom-slug
    theme: dark
    expiry: 7d
    password: secret
    description: A short summary
    ---
    # Document body
    ```

    Frontmatter fields:

    | Field       | Type   | Description                                              |
    |-------------|--------|----------------------------------------------------------|
    | title       | string | Document title (overrides H1 extraction)                 |
    | slug        | string | Custom URL slug (alphanumeric + hyphen, 3-128 chars)     |
    | theme       | string | Rendering theme: `clean`, `dark`, `paper`, `minimal`    |
    | expiry      | string | Expiry duration: `30m`, `24h`, `7d`, `2w` (min 5m)      |
    | password    | string | Password-protect the human view                          |
    | description | string | Short description (returned in API responses)            |

  contact:
    name: Geoff Baum
    url: https://github.com/gabaum10/twofold

servers:
  - url: http://localhost:3000
    description: Local development server

security:
  - BearerAuth: []

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: |
        Admin token (TWOFOLD_TOKEN env var) or a managed token created via
        `twofold token create`. All tokens have full access.

  schemas:
    DocumentCreated:
      type: object
      required: [url, slug, api_url, title, created_at]
      properties:
        url:
          type: string
          format: uri
          description: Human-facing URL for the document.
          example: "http://localhost:3000/board-q1"
        slug:
          type: string
          description: URL slug (auto-generated or custom).
          example: "board-q1"
        api_url:
          type: string
          format: uri
          description: Agent API URL for raw markdown retrieval.
          example: "http://localhost:3000/api/v1/documents/board-q1"
        title:
          type: string
          description: Document title (frontmatter > H1 > slug).
          example: "Board Report Q1"
        description:
          type: string
          nullable: true
          description: Short description from frontmatter.
          example: "Q1 summary for the board"
        created_at:
          type: string
          format: date-time
          description: ISO 8601 UTC creation timestamp.
          example: "2026-05-10T03:22:00Z"
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: ISO 8601 UTC expiry timestamp. Null if no expiry.
          example: "2026-05-17T03:22:00Z"

    DocumentSummary:
      type: object
      required: [slug, title, created_at]
      properties:
        slug:
          type: string
          example: "board-q1"
        title:
          type: string
          example: "Board Report Q1"
        description:
          type: string
          nullable: true
          example: "Q1 summary for the board"
        created_at:
          type: string
          format: date-time
          example: "2026-05-10T03:22:00Z"
        expires_at:
          type: string
          format: date-time
          nullable: true
          example: null

    DocumentList:
      type: object
      required: [documents, total, limit, offset]
      properties:
        documents:
          type: array
          items:
            $ref: "#/components/schemas/DocumentSummary"
        total:
          type: integer
          description: Total count of non-expired documents (for pagination).
          example: 42
        limit:
          type: integer
          description: The limit applied to this response.
          example: 20
        offset:
          type: integer
          description: The offset applied to this response.
          example: 0

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable error message.
          example: "Not found"

paths:
  /api/v1/documents:
    post:
      summary: Create a document
      operationId: createDocument
      description: |
        Publish a new markdown document. The request body is raw markdown text
        (Content-Type: text/markdown). Optional YAML frontmatter at the top of the
        body controls title, slug, theme, expiry, password, and description.

        Returns 409 Conflict if a custom slug is already in use.
        Returns 413 Payload Too Large if the body exceeds TWOFOLD_MAX_SIZE (default 1MB).
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          text/markdown:
            schema:
              type: string
              description: |
                Raw markdown content, optionally preceded by YAML frontmatter.
              example: |
                ---
                title: Board Report Q1
                slug: board-q1
                theme: clean
                expiry: 7d
                ---
                # Board Report Q1

                Revenue up 12% quarter-over-quarter.
      responses:
        "201":
          description: Document created successfully.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DocumentCreated"
        "400":
          description: |
            Bad request. Possible causes:
            - Empty request body
            - Invalid UTF-8 in body
            - Invalid frontmatter YAML
            - Invalid slug format (reserved, too short, invalid chars, starts/ends with hyphen)
            - Invalid expiry format or out of range
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing or invalid bearer token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Custom slug already in use.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          description: Request body exceeds the configured maximum size.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

    get:
      summary: List documents
      operationId: listDocuments
      description: |
        Returns a paginated list of non-expired document summaries, ordered by
        creation date descending (newest first).

        Does NOT return raw document content — metadata only.
        Expired documents are excluded from results and from the total count.
      security:
        - BearerAuth: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
          description: Maximum number of results to return. Capped at 100.
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
          description: Number of results to skip. Values below 0 are treated as 0.
      responses:
        "200":
          description: Document list returned successfully.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DocumentList"
              example:
                documents:
                  - slug: "board-q1"
                    title: "Board Report Q1"
                    description: "Q1 summary"
                    created_at: "2026-05-10T03:22:00Z"
                    expires_at: null
                total: 1
                limit: 20
                offset: 0
        "401":
          description: Missing or invalid bearer token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/documents/{slug}:
    parameters:
      - name: slug
        in: path
        required: true
        schema:
          type: string
        description: Document slug (alphanumeric + hyphen).
        example: "board-q1"

    get:
      summary: Get document (agent view)
      operationId: getDocument
      description: |
        Returns the full raw markdown source exactly as it was POSTed,
        including any frontmatter and `<!-- @agent -->` sections.

        This endpoint is NOT password-gated — it is intended for agent access.
        Password-protected documents are readable here with a valid bearer token.

        Returns 410 Gone if the document has expired.
      security:
        - BearerAuth: []
      responses:
        "200":
          description: Raw markdown content.
          content:
            text/markdown:
              schema:
                type: string
              example: |
                ---
                title: Board Report Q1
                ---
                # Board Report Q1

                Revenue up 12%.
        "401":
          description: Missing or invalid bearer token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Document not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "410":
          description: Document has expired and been deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

    put:
      summary: Update a document
      operationId: updateDocument
      description: |
        Replace the content of an existing document. The slug in the URL is
        authoritative — any slug field in frontmatter is ignored on PUT.

        Expiry, theme, and password can be changed on update. To remove an expiry,
        omit the `expiry` frontmatter field. To remove a password, omit the
        `password` frontmatter field (or set it to an empty string).

        Returns 410 Gone if the document has expired.
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          text/markdown:
            schema:
              type: string
              description: New markdown content for the document.
      responses:
        "200":
          description: Document updated successfully.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DocumentCreated"
        "400":
          description: Bad request (empty body, invalid frontmatter, invalid expiry).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing or invalid bearer token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Document not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "410":
          description: Document has expired.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

    delete:
      summary: Delete a document
      operationId: deleteDocument
      description: |
        Permanently delete a document by slug. Returns 204 on success.
        Expired documents can still be explicitly deleted (cleanup use case).
        Returns 404 if the document does not exist.
      security:
        - BearerAuth: []
      responses:
        "204":
          description: Document deleted successfully. No response body.
        "401":
          description: Missing or invalid bearer token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Document not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/v1/openapi.yaml:
    get:
      summary: OpenAPI spec (YAML)
      operationId: getOpenApiYaml
      description: |
        Returns this OpenAPI specification as YAML (OpenAPI 3.1.0).
        Public — no authentication required.
      security: []
      responses:
        "200":
          description: OpenAPI 3.1.0 specification in YAML format.
          content:
            application/yaml:
              schema:
                type: string

  /api/v1/openapi.json:
    get:
      summary: OpenAPI spec (JSON)
      operationId: getOpenApiJson
      description: |
        Returns the OpenAPI specification as JSON (derived from the canonical YAML).
        Some tools prefer JSON over YAML. Public — no authentication required.
      security: []
      responses:
        "200":
          description: OpenAPI 3.1.0 specification in JSON format.
          content:
            application/json:
              schema:
                type: object

  /{slug}:
    parameters:
      - name: slug
        in: path
        required: true
        schema:
          type: string
        description: Document slug.
        example: "board-q1"
      - name: raw
        in: query
        schema:
          type: string
          enum: ["1"]
        description: |
          When set to `1`, returns the raw markdown source instead of rendered HTML.
          Subject to the same password gate as the human view.

    get:
      summary: Human view (themed HTML or raw markdown)
      operationId: getHumanView
      description: |
        The primary human-facing endpoint. Returns a fully rendered, styled HTML page.

        - `<!-- @agent -->` / `<!-- @end -->` sections are stripped from the human view.
        - Frontmatter is stripped from the rendered output.
        - Code blocks are syntax-highlighted.
        - If the document is password-protected and the user has not authenticated,
          returns the password prompt page (200, not 401).

        With `?raw=1`: returns the full raw markdown source (password-gated).

        Returns 410 Gone if the document has expired.
      security: []
      responses:
        "200":
          description: |
            Rendered HTML page (or raw markdown if ?raw=1, or password prompt if protected).
          content:
            text/html:
              schema:
                type: string
            text/markdown:
              schema:
                type: string
        "302":
          description: Internal redirect (not exposed externally).
        "404":
          description: Document not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "410":
          description: Document has expired.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /{slug}/full:
    parameters:
      - name: slug
        in: path
        required: true
        schema:
          type: string
        description: Document slug.

    get:
      summary: Full rendered view (agent sections included)
      operationId: getFullView
      description: |
        Renders the full document including content from `<!-- @agent -->` sections.
        The marker comment lines themselves are stripped, but their content is kept.

        `<!-- @instructions -->` / `<!-- @end-instructions -->` blocks are removed
        entirely (both markers and content).

        Password-protected documents still require authentication via cookie.
        Returns 410 Gone if the document has expired.
      security: []
      responses:
        "200":
          description: Full rendered HTML page.
          content:
            text/html:
              schema:
                type: string
        "302":
          description: Password gate redirect (not exposed externally).
        "404":
          description: Document not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "410":
          description: Document has expired.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /{slug}/unlock:
    parameters:
      - name: slug
        in: path
        required: true
        schema:
          type: string
        description: Document slug.

    post:
      summary: Unlock a password-protected document
      operationId: unlockDocument
      description: |
        Verifies the password for a protected document. On success, sets an
        HttpOnly HMAC-signed cookie and redirects (303) to the document view.

        On failure, returns the password prompt page again with an error message.

        The auth cookie is valid for 1 hour.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [password]
              properties:
                password:
                  type: string
                  description: The document password.
      responses:
        "303":
          description: Password correct. Redirects to the document view with auth cookie set.
          headers:
            Location:
              schema:
                type: string
              description: URL of the document.
            Set-Cookie:
              schema:
                type: string
              description: HttpOnly HMAC-signed auth cookie, valid for 1 hour.
        "200":
          description: |
            Password incorrect. Returns password prompt page with error message.
            (Not a 4xx — the form is re-displayed.)
          content:
            text/html:
              schema:
                type: string
        "404":
          description: Document not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "410":
          description: Document has expired.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
