openapi: 3.0.3
info:
  title: tinyhost.win
  version: 0.1.0
  description: |
    Identity-less static hosting for coding Agents. An Agent Publishes a Site;
    a Visitor opens it at https://{slug}.tinyhost.win/. There are no accounts.

    Publish is a three-step loop: create (or replace) -> upload files -> finalize.
    A Site is not live until finalize succeeds. The Site secret is returned once
    at create and authorizes every later mutation; it is never shown again.

    A Site expires seven days after create. The Expiry is fixed; later Publishes
    do not extend it. After Expiry, mutations return 410 `gone` and Visitor GETs 404.

    The bundled CLI (https://tinyhost.win/skill.md) is the supported client of
    this contract.
  contact:
    url: https://tinyhost.win
servers:
  - url: https://api.tinyhost.win
tags:
  - name: sites
    description: The Publish loop
paths:
  /v1/drop:
    post:
      tags: [sites]
      summary: Publish one document — create and go live in one call
      description: |
        The one-call Publish path. Send one Markdown or HTML document; it becomes
        `index.html` of a fresh, live Site. There is no separate upload or
        finalize step — the response already holds a live `siteUrl`.

        `kind` is `"markdown"` (rendered to HTML with a readable default style;
        raw HTML in the source is escaped, never injected) or `"html"` (a full
        Agent-authored HTML document, served verbatim). `title` is optional and
        used only for Markdown documents.

        The Site secret is returned once here, as with create. Use `PUT
        /v1/drop/{slug}` to replace the document later.
      operationId: dropSite
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DropBody"
      responses:
        "200":
          description: Site created and live
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateSite"
        "400":
          $ref: "#/components/responses/Invalid"
        "413":
          $ref: "#/components/responses/TooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
  /v1/drop/{slug}:
    put:
      tags: [sites]
      summary: Replace a dropped Site's document in one call
      description: |
        Renders a new document and swaps it in as the live `index.html`, keeping
        the Slug, the Site secret, and the Expiry. The Site must already exist.
      operationId: dropReplace
      security:
        - siteSecret: []
      parameters:
        - $ref: "#/components/parameters/Slug"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DropBody"
      responses:
        "200":
          description: Document replaced and live
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReplaceSite"
        "400":
          $ref: "#/components/responses/Invalid"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "410":
          $ref: "#/components/responses/Gone"
        "413":
          $ref: "#/components/responses/TooLarge"
        "429":
          $ref: "#/components/responses/RateLimited"
  /v1/sites:
    post:
      tags: [sites]
      summary: Create a Site
      description: |
        Creates a Site with a host-assigned Slug and a fixed Expiry seven days
        out. The `siteSecret` is returned only in this response — store it
        immediately. Uploads go to the pending tree; nothing is live until
        finalize.
      operationId: createSite
      security: []
      responses:
        "200":
          description: Site created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateSite"
        "429":
          $ref: "#/components/responses/RateLimited"
  /v1/sites/{slug}:
    post:
      tags: [sites]
      summary: Start a replace
      description: |
        Starts a fresh pending tree for a full-tree replace. The old live tree
        keeps serving until finalize swaps the pointer. The Site secret is not
        re-issued; the Expiry does not change.
      operationId: replaceSite
      security:
        - siteSecret: []
      parameters:
        - $ref: "#/components/parameters/Slug"
      responses:
        "200":
          description: Replace started; upload files next
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReplaceSite"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "410":
          $ref: "#/components/responses/Gone"
        "429":
          $ref: "#/components/responses/RateLimited"
    delete:
      tags: [sites]
      summary: Delete a Site
      description: Removes the Site immediately and frees the IP's live-Site slot.
      operationId: deleteSite
      security:
        - siteSecret: []
      parameters:
        - $ref: "#/components/parameters/Slug"
      responses:
        "200":
          description: Site deleted
          content:
            application/json:
              schema:
                type: object
                required: [slug, deleted]
                properties:
                  slug:
                    $ref: "#/components/schemas/Slug"
                  deleted:
                    type: boolean
                    example: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "410":
          $ref: "#/components/responses/Gone"
  /v1/sites/{slug}/files:
    put:
      tags: [sites]
      summary: Upload one file
      description: |
        Uploads one file's bytes into the pending tree. Re-uploading the same
        path overwrites it. Caps: 10 MB per file, 50 MB and 500 files per Site.
      operationId: uploadFile
      security:
        - siteSecret: []
      parameters:
        - $ref: "#/components/parameters/Slug"
        - name: path
          in: query
          required: true
          description: Clean relative file path (no leading `/`, no `.` or `..` segments)
          schema:
            type: string
            maxLength: 1024
          example: assets/app.css
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
      responses:
        "200":
          description: File stored in the pending tree
          content:
            application/json:
              schema:
                type: object
                required: [path, size]
                properties:
                  path:
                    type: string
                    example: assets/app.css
                  size:
                    type: integer
                    example: 4812
        "400":
          $ref: "#/components/responses/Invalid"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "410":
          $ref: "#/components/responses/Gone"
        "413":
          $ref: "#/components/responses/TooLarge"
  /v1/sites/{slug}/finalize:
    post:
      tags: [sites]
      summary: Make the pending tree live
      description: |
        Swaps the pending tree in as the live tree in one metadata update.
        The previous live tree and any abandoned pending trees are deleted in
        the background. Until finalize succeeds, Visitors see the old tree (or
        404 if the Site was never finalized).
      operationId: finalizeSite
      security:
        - siteSecret: []
      parameters:
        - $ref: "#/components/parameters/Slug"
      responses:
        "200":
          description: Pending tree is live
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReplaceSite"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "410":
          $ref: "#/components/responses/Gone"
components:
  securitySchemes:
    siteSecret:
      type: apiKey
      in: header
      name: X-Tinyhost-Site-Secret
      description: The Site secret returned once by `POST /v1/sites`.
  parameters:
    Slug:
      name: slug
      in: path
      required: true
      description: Host-assigned Site identifier
      schema:
        $ref: "#/components/schemas/Slug"
  schemas:
    Slug:
      type: string
      pattern: ^[a-z]+-[a-z]+-[a-z0-9]{4}$
      example: sharp-harbor-lktv
    DropBody:
      type: object
      required: [kind, content]
      properties:
        kind:
          type: string
          enum: [markdown, html]
          description: |
            `markdown` is rendered to HTML with a safe renderer (raw HTML in the
            source is escaped, never injected) and wrapped with a readable
            default style. `html` is a full Agent-authored document, served as-is.
        content:
          type: string
          maxLength: 1048576
          description: The document body. At most 1 MB.
        title:
          type: string
          description: Optional; used only for Markdown documents.
    CreateSite:
      type: object
      required: [slug, siteUrl, siteSecret, expiresAt]
      properties:
        slug:
          $ref: "#/components/schemas/Slug"
        siteUrl:
          type: string
          format: uri
          example: https://sharp-harbor-lktv.tinyhost.win/
        siteSecret:
          type: string
          description: Returned only here, only once
        expiresAt:
          type: string
          format: date-time
          description: Fixed at create; seven days out; never extended
    ReplaceSite:
      type: object
      required: [slug, siteUrl, version]
      properties:
        slug:
          $ref: "#/components/schemas/Slug"
        siteUrl:
          type: string
          format: uri
        version:
          type: integer
          description: The pending tree's version number
          example: 2
    Error:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          enum: [unauthorized, gone, rate_limited, too_large, not_found, invalid]
        message:
          type: string
  responses:
    Unauthorized:
      description: Missing or wrong Site secret
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            code: unauthorized
            message: Wrong Site secret
    NotFound:
      description: Unknown Site
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            code: not_found
            message: Unknown Site
    Gone:
      description: The Site has expired
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            code: gone
            message: Site has expired
    RateLimited:
      description: Too many creates or replaces from this IP
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            code: rate_limited
            message: Too many creates from this IP; try again later
    TooLarge:
      description: File or Site over the size or file-count cap
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            code: too_large
            message: A file may be at most 10485760 bytes
    Invalid:
      description: Malformed request
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            code: invalid
            message: Query param 'path' must be a clean relative file path
