openapi: 3.0.3
info:
  title: Simple Host API
  description: >-
    Static website hosting with a light per-site backend. Deploy a site with one
    API call — inline JSON files (LLM-friendly) or a tar.gz/zip archive — and each
    site gets shared JSON state (with atomic ops), append-only collections,
    optional password (view-lock) protection, and starter templates. Sign-in is a
    6-digit email code or Google/GitHub that returns an API key, sent as the
    `X-API-Key` header. Hosted-page sign-in uses the same account but a
    site-scoped session cookie — never an API key.
  version: 2.0.0

servers:
  - url: https://simple-host.app
    description: Production

tags:
  - name: Auth
    description: Email-code sign-in and dashboard OAuth handoff (`/?token=`)
  - name: Visitor
    description: >-
      Site-scoped browser session for hosted-page Google/GitHub sign-in.
      Same `users` row as email-code / dashboard OAuth; the cookie is not an
      API key and does not satisfy owner routes.
  - name: Deploy
    description: Create/update a site (JSON files or archive)
  - name: Sites
    description: List, versions, rollback, delete
  - name: State
    description: Per-site shared JSON store (reads Origin-gated; writes need a visitor session or owner key)
  - name: Collections
    description: Append-only per-site lists (POST is a write)
  - name: Private pages
    description: Password (view-lock) protection
  - name: Custom domains
    description: Bind one custom domain per site (CNAME + on-demand TLS)
  - name: Analytics
    description: Per-site visitor analytics (server-side, owner-scoped)
  - name: Templates
    description: Starter templates
  - name: AI
    description: Generate a site from a prompt
  - name: Health

paths:
  /v1/skills:
    get:
      operationId: listSkills
      summary: Skills hub catalog — discover the bundled agent skills
      description: Public catalog of the skills this server bundles (name + description + fetch URL), so any agent can search them over HTTP. No auth.
      responses:
        '200':
          description: Skill catalog
          content:
            application/json:
              schema:
                type: object
                properties:
                  plugin: { type: string }
                  version: { type: string }
                  count: { type: integer }
                  skills:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string }
                        description: { type: string }
                        url: { type: string }
  /v1/skills/{name}:
    get:
      operationId: getSkill
      summary: Fetch one skill's SKILL.md (markdown)
      description: Returns the raw SKILL.md for the named skill. Public, no auth.
      parameters:
        - name: name
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: The skill's SKILL.md
          content:
            text/markdown:
              schema: { type: string }
        '404': { description: No such skill }
  /v1/skills/{name}/references/{file}:
    get:
      operationId: getSkillReference
      summary: Fetch one of a skill's reference documents (markdown)
      description: >-
        A skill's SKILL.md routes to reference documents under its references/
        directory. A folder install has them on disk; install methods that fetch
        only SKILL.md by URL do not, so SKILL.md cites each reference by
        absolute URL as well and this endpoint serves it. Public, no auth.
      parameters:
        - name: name
          in: path
          required: true
          schema: { type: string }
        - name: file
          in: path
          required: true
          description: The reference filename, e.g. backend.md
          schema: { type: string }
      responses:
        '200':
          description: The reference document
          content:
            text/markdown:
              schema: { type: string }
        '400': { description: Invalid skill or reference name }
        '404': { description: No such reference }
  /v1/auth:
    post:
      operationId: postAuth
      summary: Request a sign-in code
      description: >-
        Emails a 6-digit code (and a magic link) to the address. No user row is
        created here — the account is created lazily on the first successful
        verify, so this can't be used to enumerate users.
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email, example: you@example.com }
      responses:
        '202':
          description: Code sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  email: { type: string }
                  expires_in_seconds: { type: integer, example: 900 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /v1/auth/verify:
    post:
      operationId: postAuthVerify
      summary: Verify the code and get an API key
      description: >-
        Exchange `{email, code}` (CLI/agent) or a magic-link `{token}` (browser)
        for an API key. The first successful email-code verify creates the
        account. Dashboard Google/GitHub also redeems through this endpoint
        (`/?token=`). The code expires in 15 minutes and allows 3 attempts.
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: string, format: email }
                code: { type: string, example: '123456' }
                token: { type: string, description: magic-link token (alternative to email+code) }
      responses:
        '200':
          description: Authenticated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401':
          description: Invalid or expired code
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/auth/oauth/providers:
    get:
      operationId: getOAuthProviders
      summary: List enabled OAuth providers
      description: >-
        Public discovery for widgets and the dashboard. Returns the enabled
        provider names (`google`, `github`) so pages never hardcode a provider.
        Empty when neither pair is configured. Sign-in creates or links a
        `users` row; a hosted-page session still does not disclose an API key.
      tags: [Visitor]
      responses:
        '200':
          description: Enabled providers
          content:
            application/json:
              schema:
                type: object
                required: [providers]
                properties:
                  providers:
                    type: array
                    items: { type: string, enum: [google, github] }
                    example: [google, github]

  /v1/auth/oauth/{provider}:
    get:
      operationId: startOAuth
      summary: Start OAuth
      description: >-
        Apex start. Query `return_to` is required and must be an absolute URL.
        Two shapes: the dashboard origin `/` (owner purpose — callback hands
        off via `/?token=`) or a real content-host path (`/{handle}/{sitename}/…`)
        / DNS-proven custom domain (site purpose — callback issues a
        host-and-site-scoped session). Success 302s to the provider with PKCE
        S256. Unknown or disabled provider is 404. No cookie is set here. The
        caller cannot set `purpose`; it is derived from `return_to`.
      tags: [Visitor]
      parameters:
        - name: provider
          in: path
          required: true
          schema: { type: string, enum: [google, github] }
        - name: return_to
          in: query
          required: true
          schema: { type: string, format: uri }
          description: Absolute URL of the page to return to after sign-in
      responses:
        '302':
          description: Redirect to the identity provider
          headers:
            Location: { schema: { type: string, format: uri } }
        '400':
          description: Invalid or missing return_to
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: invalid return_to }
        '404':
          description: Unknown or disabled provider
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: not found }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /v1/auth/oauth/{provider}/callback:
    get:
      operationId: oauthCallback
      summary: OAuth callback
      description: >-
        Provider redirect URI on the apex. Consumes the one-time `state`.
        Identifies via userinfo, then `resolveUser` (link or create a `users`
        row on a verified email; refuse missing/unverified email). Site
        purpose 302s to `https://<return-host>/v1/visitor/establish?once=…`.
        Owner purpose 302s to `{PUBLIC_BASE_URL}/?token=…` for the dashboard
        to redeem via `POST /v1/auth/verify`. Neither redirect contains an
        API key. Failures are a short no-store HTML page on the apex with no
        Location and no cookie.
      tags: [Visitor]
      parameters:
        - name: provider
          in: path
          required: true
          schema: { type: string, enum: [google, github] }
        - name: code
          in: query
          schema: { type: string }
        - name: state
          in: query
          schema: { type: string }
        - name: error
          in: query
          schema: { type: string }
      responses:
        '302':
          description: >-
            Site purpose: redirect to /v1/visitor/establish on the stored host.
            Owner purpose: redirect to /?token= on PUBLIC_BASE_URL.
          headers:
            Location: { schema: { type: string, format: uri } }
        '400':
          description: Bad, replayed, or expired state, or provider error
          content:
            text/html:
              schema: { type: string }
        '404':
          description: Unknown or disabled provider
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '502':
          description: Token or userinfo failure
          content:
            text/html:
              schema: { type: string }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /v1/visitor/establish:
    get:
      operationId: establishVisitor
      summary: Establish the visitor session cookie
      description: >-
        One-time hop on the content host or custom domain. Consumes `once`,
        host-matches, sets `__Host-sh_vsess` (or `sh_vsess` on local HTTP),
        then 302s to the stored `return_to` with no query mutation. Never sets
        a cookie on the apex. Failures are HTML, no cookie, no redirect.
      tags: [Visitor]
      parameters:
        - name: once
          in: query
          required: true
          schema: { type: string }
      responses:
        '302':
          description: Cookie set; redirect to the stored return_to
          headers:
            Location: { schema: { type: string, format: uri } }
            Set-Cookie: { schema: { type: string } }
        '400':
          description: Bad, replayed, expired, host-mismatch, or apex Host
          content:
            text/html:
              schema: { type: string }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /v1/visitor/logout:
    post:
      operationId: logoutVisitor
      summary: Log out this visitor session
      description: >-
        Deletes the session named by the visitor cookie and clears the cookie.
        Requires `X-SH-CSRF: 1` or `Content-Type: application/json` (not a
        simple form post).
      tags: [Visitor]
      parameters:
        - name: X-SH-CSRF
          in: header
          required: false
          schema: { type: string, enum: ['1'] }
      responses:
        '204': { description: Session deleted and cookie cleared }
        '403':
          description: Missing CSRF header (and not a JSON request)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, example: csrf_required }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /v1/me:
    get:
      operationId: getMe
      summary: Current user
      tags: [Auth]
      security: [{ apiKey: [] }]
      responses:
        '200':
          description: Current user
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  username: { type: string }
                  is_admin: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/sites:
    get:
      operationId: getSites
      summary: List your sites
      description: Returns the authenticated user's sites. Admins see all sites.
      tags: [Sites]
      security: [{ apiKey: [] }]
      responses:
        '200':
          description: List of sites
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/Site' } }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/sites/{sitename}/files:
    post:
      operationId: postSitesBySitenameFiles
      summary: Deploy a site from inline JSON files
      description: >-
        The LLM-friendly deploy path — send every file inline in one request, no
        archiving. `index.html` is required. Relative paths only (`..`/absolute are
        rejected); secret files (`.env`, `.git/*`, `id_rsa`) are dropped and script
        extensions (`.sh .py .php …`) are rejected. Use PUT to update an existing site.
      tags: [Deploy]
      security: [{ apiKey: [] }]
      parameters: [ { $ref: '#/components/parameters/sitename' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FilesBody' }
      responses:
        '201':
          description: Site created
          content: { application/json: { schema: { $ref: '#/components/schemas/SiteWithNote' } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
    put:
      operationId: putSitesBySitenameFiles
      summary: Update a site from inline JSON files
      description: Same as POST, but for an existing site you own. Creates a new version and activates it.
      tags: [Deploy]
      security: [{ apiKey: [] }]
      parameters: [ { $ref: '#/components/parameters/sitename' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FilesBody' }
      responses:
        '200':
          description: Site updated
          content: { application/json: { schema: { $ref: '#/components/schemas/SiteWithNote' } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/sites/{sitename}:
    post:
      operationId: postSitesBySitename
      summary: Create a site from an archive
      description: Upload a `.tar.gz` or `.zip` (for framework builds, binary assets, large sites). Max 100 MB.
      tags: [Deploy]
      security: [{ apiKey: [] }]
      parameters: [ { $ref: '#/components/parameters/sitename' } ]
      requestBody:
        required: true
        content:
          application/gzip: { schema: { type: string, format: binary } }
          application/zip: { schema: { type: string, format: binary } }
      responses:
        '201': { description: Site created, content: { application/json: { schema: { $ref: '#/components/schemas/SiteWithNote' } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
    put:
      operationId: putSitesBySitename
      summary: Update a site from an archive
      description: Upload a new version of an existing site you own. Creates a new version and activates it.
      tags: [Deploy]
      security: [{ apiKey: [] }]
      parameters: [ { $ref: '#/components/parameters/sitename' } ]
      requestBody:
        required: true
        content:
          application/gzip: { schema: { type: string, format: binary } }
          application/zip: { schema: { type: string, format: binary } }
      responses:
        '200': { description: Site updated, content: { application/json: { schema: { $ref: '#/components/schemas/SiteWithNote' } } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '413': { $ref: '#/components/responses/TooLarge' }
    delete:
      operationId: deleteSitesBySitename
      summary: Delete your site
      description: Deletes the site you own — all versions and files from disk.
      tags: [Sites]
      security: [{ apiKey: [] }]
      parameters: [ { $ref: '#/components/parameters/sitename' } ]
      responses:
        '204': { description: Site deleted }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/sites/{sitename}/versions:
    get:
      operationId: getSitesBySitenameVersions
      summary: List a site's versions
      tags: [Sites]
      security: [{ apiKey: [] }]
      parameters: [ { $ref: '#/components/parameters/sitename' } ]
      responses:
        '200':
          description: Versions (newest first)
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    version_number: { type: integer }
                    created_at: { type: string, format: date-time }
                    is_active: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/sites/{sitename}/active-version:
    put:
      operationId: putSitesBySitenameActiveVersion
      summary: Roll back to a version
      description: Re-point the live site at an existing version number.
      tags: [Sites]
      security: [{ apiKey: [] }]
      parameters: [ { $ref: '#/components/parameters/sitename' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [version_number]
              properties: { version_number: { type: integer, example: 2 } }
      responses:
        '200': { description: Active version changed }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/sites/{sitename}/visibility:
    put:
      operationId: putSitesBySitenameVisibility
      summary: Set showcase visibility
      description: >-
        Set whether this site is listed on the owner's public showcase at
        sites.<domain>/<handle>. `public` = listed on the showcase; `unlisted` =
        still reachable by direct link but hidden from the showcase. Owner only
        (API key).
      tags: [Sites]
      security: [{ apiKey: [] }]
      parameters: [ { $ref: '#/components/parameters/sitename' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [visibility]
              properties: { visibility: { type: string, enum: [public, unlisted], example: unlisted } }
      responses:
        '200': { description: Visibility updated }
        '400': { description: Invalid visibility value }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/sites/{sitename}/analytics:
    get:
      operationId: getSitesBySitenameAnalytics
      summary: Get per-site visitor analytics
      description: >-
        Owner-scoped daily view and visitor counts derived server-side from the
        access log (no client beacon). `visitors` are distinct hashed IPs per
        UTC day — salt rotates daily, so a returning-next-day visitor counts
        once per day in the range total. `daily` is zero-filled dense for the
        requested window (oldest → newest). Owner only (API key).
      tags: [Analytics]
      security: [{ apiKey: [] }]
      parameters:
        - { $ref: '#/components/parameters/sitename' }
        - name: days
          in: query
          required: false
          description: Number of UTC days to include (default 30, clamp 1..365)
          schema: { type: integer, minimum: 1, maximum: 365, default: 30 }
      responses:
        '200':
          description: Analytics for the site
          content:
            application/json:
              schema:
                type: object
                properties:
                  range_days: { type: integer, example: 30 }
                  totals:
                    type: object
                    properties:
                      views: { type: integer, example: 1234 }
                      visitors: { type: integer, example: 456 }
                  daily:
                    type: array
                    items:
                      type: object
                      properties:
                        day: { type: string, format: date, example: "2026-07-11" }
                        views: { type: integer, example: 42 }
                        visitors: { type: integer, example: 30 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/sites/{sitename}/state:
    get:
      operationId: getSitesBySitenameState
      summary: Read per-site JSON state
      description: >-
        Returns the JSON blob for this site (`null` if unset). PUBLIC store — no API
        key; the server checks `Origin`/`Referer` and only accepts calls from the
        site's own page (`https://{sitename}.simple-host.app`). The response carries
        an `ETag`; send `If-None-Match: <etag>` to get a `304` when nothing changed
        (cheap polling). Never store secrets here.
      tags: [State]
      parameters:
        - { $ref: '#/components/parameters/sitename' }
        - { name: If-None-Match, in: header, required: false, schema: { type: string } }
      responses:
        '200':
          description: Current state (arbitrary JSON, `null` if unset)
          headers: { ETag: { schema: { type: string } } }
          content: { application/json: { schema: {} } }
        '304': { description: Not modified (matched If-None-Match) }
        '403': { $ref: '#/components/responses/OriginMismatch' }
    put:
      operationId: putSitesBySitenameState
      summary: Replace per-site JSON state
      description: >-
        Overwrites the whole blob (≤ 1 MB), last-write-wins. Optionally pass
        `If-Match: <etag>` for optimistic concurrency (returns 412 on conflict).
        Origin-gated. Writes require a visitor session (`X-SH-CSRF: 1`) or the
        owner's `X-API-Key`.
      tags: [State]
      parameters:
        - { $ref: '#/components/parameters/sitename' }
        - { name: If-Match, in: header, required: false, schema: { type: string } }
        - { $ref: '#/components/parameters/csrf' }
      requestBody: { required: true, content: { application/json: { schema: {} } } }
      responses:
        '200': { description: Saved; echoes the value, headers: { ETag: { schema: { type: string } } }, content: { application/json: { schema: {} } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/VisitorAuthRequired' }
        '403': { $ref: '#/components/responses/OriginMismatch' }
        '412': { description: ETag did not match (If-Match) }
        '413': { $ref: '#/components/responses/TooLarge' }
    patch:
      operationId: patchSitesBySitenameState
      summary: Atomically update per-site state
      description: >-
        Apply one or more atomic operations so concurrent writers never clobber.
        Origin-gated. Writes require a visitor session (`X-SH-CSRF: 1`) or the
        owner's `X-API-Key`. Ops — `set` (path,value), `inc` (path,by),
        `append` (path,value), `remove` (path), `removeWhere` (path,match).
      tags: [State]
      parameters:
        - { $ref: '#/components/parameters/sitename' }
        - { $ref: '#/components/parameters/csrf' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ops]
              properties:
                ops:
                  type: array
                  items:
                    type: object
                    properties:
                      op: { type: string, enum: [set, inc, append, remove, removeWhere] }
                      path: { type: string, example: 'counters.visits' }
                      value: {}
                      by: { type: number }
                      match: { type: object }
              example: { ops: [ { op: inc, path: count, by: 1 }, { op: append, path: items, value: { id: 'x' } } ] }
      responses:
        '200': { description: New state, headers: { ETag: { schema: { type: string } } }, content: { application/json: { schema: {} } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/VisitorAuthRequired' }
        '403': { $ref: '#/components/responses/OriginMismatch' }

  /v1/sites/{sitename}/collections:
    get:
      operationId: getSitesBySitenameCollections
      summary: List collections a site has saved
      description: >-
        Owner-only inventory of named collections on this site, with row count
        and most-recent write. Empty site returns `{collections:[]}`.
      tags: [Collections]
      security: [{ apiKey: [] }]
      parameters: [ { $ref: '#/components/parameters/sitename' } ]
      responses:
        '200':
          description: Collections
          content:
            application/json:
              schema:
                type: object
                properties:
                  collections:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string, example: rsvps }
                        count: { type: integer, example: 7 }
                        last_at: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/sites/{sitename}/collections/{coll}:
    post:
      operationId: postSitesBySitenameCollectionsByColl
      summary: Append an item to a collection
      description: >-
        Append-only list (O(1) insert). For signups, RSVPs, submissions.
        Origin-gated. Writes require a visitor session (`X-SH-CSRF: 1`) or the
        owner's `X-API-Key`. Each item ≤ 64 KB.
      tags: [Collections]
      parameters:
        - { $ref: '#/components/parameters/sitename' }
        - { $ref: '#/components/parameters/coll' }
        - { $ref: '#/components/parameters/csrf' }
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses:
        '201': { description: Item appended }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/VisitorAuthRequired' }
        '403': { $ref: '#/components/responses/OriginMismatch' }
    get:
      operationId: getSitesBySitenameCollectionsByColl
      summary: List items in a collection
      description: >-
        Newest-first, paginated. Origin-gated (no API key) for the site's own
        page, or owner/admin API key for the dashboard. Writes (POST) stay
        Origin-only.
      tags: [Collections]
      parameters:
        - { $ref: '#/components/parameters/sitename' }
        - { $ref: '#/components/parameters/coll' }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 50, maximum: 200 } }
        - { name: before, in: query, required: false, schema: { type: string }, description: cursor for the next page }
      responses:
        '200':
          description: Items
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { type: object } }
        '403': { $ref: '#/components/responses/OriginMismatch' }

  /v1/sites/{sitename}/collections/{coll}/export.csv:
    get:
      operationId: exportSitesBySitenameCollectionsByColl
      summary: Download a collection as CSV
      description: >-
        Owner-only. Streams the whole collection (not capped at the public
        200-row read limit). Columns are `id`, `created_at`, then the sorted
        union of JSON keys across all rows.
      tags: [Collections]
      security: [{ apiKey: [] }]
      parameters:
        - { $ref: '#/components/parameters/sitename' }
        - { $ref: '#/components/parameters/coll' }
      responses:
        '200':
          description: CSV attachment
          content:
            text/csv:
              schema: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/u/{handle}/sites/{sitename}/state:
    get:
      operationId: getSiteStateByHandle
      summary: Read per-site JSON state (v3, by handle)
      description: >-
        Returns the JSON blob for this site (`null` if unset). PUBLIC store — no API
        key; the server checks `Origin`/`Referer` and only accepts calls from the
        site's own page (`https://{sitename}.simple-host.app`). The response carries
        an `ETag`; send `If-None-Match: <etag>` to get a `304` when nothing changed
        (cheap polling). Never store secrets here.
        v3 user-scoped, path-model-canonical variant: resolves the site by
        (handle -> user_id) + (user_id, name).
      tags: [State]
      parameters:
        - { name: handle, in: path, required: true, schema: { type: string }, description: "the site owner's URL-safe handle" }
        - { $ref: '#/components/parameters/sitename' }
        - { name: If-None-Match, in: header, required: false, schema: { type: string } }
      responses:
        '200':
          description: Current state (arbitrary JSON, `null` if unset)
          headers: { ETag: { schema: { type: string } } }
          content: { application/json: { schema: {} } }
        '304': { description: Not modified (matched If-None-Match) }
        '403': { $ref: '#/components/responses/OriginMismatch' }
    put:
      operationId: putSiteStateByHandle
      summary: Replace per-site JSON state (v3, by handle)
      description: >-
        Overwrites the whole blob (≤ 1 MB), last-write-wins. Optionally pass
        `If-Match: <etag>` for optimistic concurrency (returns 412 on conflict).
        Origin-gated. Writes require a visitor session (`X-SH-CSRF: 1`) or the
        owner's `X-API-Key`.
        v3 user-scoped, path-model-canonical variant: resolves the site by
        (handle -> user_id) + (user_id, name).
      tags: [State]
      parameters:
        - { name: handle, in: path, required: true, schema: { type: string }, description: "the site owner's URL-safe handle" }
        - { $ref: '#/components/parameters/sitename' }
        - { name: If-Match, in: header, required: false, schema: { type: string } }
        - { $ref: '#/components/parameters/csrf' }
      requestBody: { required: true, content: { application/json: { schema: {} } } }
      responses:
        '200': { description: Saved; echoes the value, headers: { ETag: { schema: { type: string } } }, content: { application/json: { schema: {} } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/VisitorAuthRequired' }
        '403': { $ref: '#/components/responses/OriginMismatch' }
        '412': { description: ETag did not match (If-Match) }
        '413': { $ref: '#/components/responses/TooLarge' }
    patch:
      operationId: patchSiteStateByHandle
      summary: Atomically update per-site state (v3, by handle)
      description: >-
        Apply one or more atomic operations so concurrent writers never clobber.
        Origin-gated. Writes require a visitor session (`X-SH-CSRF: 1`) or the
        owner's `X-API-Key`. Ops — `set` (path,value), `inc` (path,by),
        `append` (path,value), `remove` (path), `removeWhere` (path,match).
        v3 user-scoped, path-model-canonical variant: resolves the site by
        (handle -> user_id) + (user_id, name).
      tags: [State]
      parameters:
        - { name: handle, in: path, required: true, schema: { type: string }, description: "the site owner's URL-safe handle" }
        - { $ref: '#/components/parameters/sitename' }
        - { $ref: '#/components/parameters/csrf' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ops]
              properties:
                ops:
                  type: array
                  items:
                    type: object
                    properties:
                      op: { type: string, enum: [set, inc, append, remove, removeWhere] }
                      path: { type: string, example: 'counters.visits' }
                      value: {}
                      by: { type: number }
                      match: { type: object }
              example: { ops: [ { op: inc, path: count, by: 1 }, { op: append, path: items, value: { id: 'x' } } ] }
      responses:
        '200': { description: New state, headers: { ETag: { schema: { type: string } } }, content: { application/json: { schema: {} } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/VisitorAuthRequired' }
        '403': { $ref: '#/components/responses/OriginMismatch' }

  /v1/u/{handle}/sites/{sitename}/collections/{coll}:
    post:
      operationId: postCollectionByHandle
      summary: Append an item to a collection (v3, by handle)
      description: >-
        Append-only list (O(1) insert). For signups, RSVPs, submissions.
        Origin-gated. Writes require a visitor session (`X-SH-CSRF: 1`) or the
        owner's `X-API-Key`. Each item ≤ 64 KB.
        v3 user-scoped, path-model-canonical variant: resolves the site by
        (handle -> user_id) + (user_id, name).
      tags: [Collections]
      parameters:
        - { name: handle, in: path, required: true, schema: { type: string }, description: "the site owner's URL-safe handle" }
        - { $ref: '#/components/parameters/sitename' }
        - { $ref: '#/components/parameters/coll' }
        - { $ref: '#/components/parameters/csrf' }
      requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
      responses:
        '201': { description: Item appended }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/VisitorAuthRequired' }
        '403': { $ref: '#/components/responses/OriginMismatch' }
    get:
      operationId: getCollectionByHandle
      summary: List items in a collection (v3, by handle)
      description: >-
        Newest-first, paginated. Origin-gated (no API key) for the site's own
        page, or owner/admin API key for the dashboard. Writes (POST) stay
        Origin-only.
        v3 user-scoped, path-model-canonical variant: resolves the site by
        (handle -> user_id) + (user_id, name).
      tags: [Collections]
      parameters:
        - { name: handle, in: path, required: true, schema: { type: string }, description: "the site owner's URL-safe handle" }
        - { $ref: '#/components/parameters/sitename' }
        - { $ref: '#/components/parameters/coll' }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 50, maximum: 200 } }
        - { name: before, in: query, required: false, schema: { type: string }, description: cursor for the next page }
      responses:
        '200':
          description: Items
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { type: object } }
        '403': { $ref: '#/components/responses/OriginMismatch' }

  /v1/sites/{sitename}/allow-anonymous-writes:
    put:
      operationId: putAllowAnonymousWrites
      summary: Admin override — allow anonymous writes on one site
      description: >-
        Sets `sites.allow_anonymous_writes`. Admin (`ADMIN_API_KEY`) only. Default
        is false. When `WRITE_AUTH_MODE=on` and this flag is true, anonymous
        writes to this site succeed and are logged with `outcome=overridden`.
        No `/v1/u/{handle}/…` twin.
      tags: [Sites]
      security: [{ apiKey: [] }]
      parameters: [ { $ref: '#/components/parameters/sitename' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [allow]
              properties: { allow: { type: boolean } }
            example: { allow: true }
      responses:
        '200':
          description: Flag updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  site: { type: string }
                  allow_anonymous_writes: { type: boolean }
              example: { site: wedding, allow_anonymous_writes: true }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/sites/{sitename}/view-password:
    put:
      operationId: putSitesBySitenameViewPassword
      summary: Set a view password (make the site private)
      description: >-
        Password-protect the whole site (view-lock). Visitors get a custom login
        page + signed cookie, and the locked page's state/collections also require
        unlocking. Owner only (API key).
      tags: [Private pages]
      security: [{ apiKey: [] }]
      parameters: [ { $ref: '#/components/parameters/sitename' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties: { password: { type: string } }
      responses:
        '204': { description: View password set }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: deleteSitesBySitenameViewPassword
      summary: Remove the view password (make the site public again)
      tags: [Private pages]
      security: [{ apiKey: [] }]
      parameters: [ { $ref: '#/components/parameters/sitename' } ]
      responses:
        '204': { description: View password removed }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/sites/{sitename}/allowed-origins:
    put:
      operationId: putSitesBySitenameAllowedOrigins
      summary: Allow external origins to use this site's backend
      description: >-
        Whitelist extra origins (scheme://host, no path) that may call this
        site's state/collections API cross-origin — so a page hosted anywhere
        (GitHub Pages, Netlify, …) can use the site as its backend for comments,
        feedback, counters, and forms. Replaces the whole list (max 20). Owner
        only. Same Origin-gated trust model as same-site calls: data stays
        public to the page's audience; never store secrets.
      tags: [State]
      security: [{ apiKey: [] }]
      parameters: [ { $ref: '#/components/parameters/sitename' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [origins]
              properties:
                origins:
                  type: array
                  maxItems: 20
                  items: { type: string, example: "https://username.github.io" }
      responses:
        '200':
          description: Allowlist replaced
          content:
            application/json:
              schema:
                type: object
                properties:
                  site: { type: string }
                  allowed_origins: { type: array, items: { type: string } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/sites/{sitename}/domain:
    post:
      operationId: postSitesBySitenameDomain
      summary: Bind a custom domain to this site
      description: >-
        Bind ONE custom domain to the site (status starts as `pending`). Returns
        the DNS CNAME record the owner must create (host → platform CNAME
        target). Once DNS points at us, on-demand TLS issues a cert and the
        domain is served at its root. Domains are globally unique; hijacking the
        platform zone is rejected. Owner only (API key).
      tags: [Custom domains]
      security: [{ apiKey: [] }]
      parameters: [ { $ref: '#/components/parameters/sitename' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                domain: { type: string, example: www.example.com }
      responses:
        '200':
          description: Domain bound (pending DNS)
          content:
            application/json:
              schema:
                type: object
                properties:
                  domain: { type: string, example: www.example.com }
                  status: { type: string, enum: [pending, active, error], example: pending }
                  dns:
                    type: object
                    properties:
                      type: { type: string, example: CNAME }
                      host: { type: string, example: www.example.com }
                      value: { type: string, example: cname.simple-host.app }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Domain already taken by another site
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    get:
      operationId: getSitesBySitenameDomain
      summary: Get the custom domain binding for this site
      description: >-
        Returns the bound domain and status, or `{domain:null,status:null}` when
        none is set. Includes the CNAME DNS record to add when a domain is bound.
        Owner only (API key).
      tags: [Custom domains]
      security: [{ apiKey: [] }]
      parameters: [ { $ref: '#/components/parameters/sitename' } ]
      responses:
        '200':
          description: Current domain binding
          content:
            application/json:
              schema:
                type: object
                properties:
                  domain: { type: string, nullable: true, example: www.example.com }
                  status: { type: string, nullable: true, enum: [pending, active, error] }
                  verified_at: { type: string, format: date-time, nullable: true }
                  last_error: { type: string }
                  dns:
                    type: object
                    properties:
                      type: { type: string, example: CNAME }
                      host: { type: string }
                      value: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: deleteSitesBySitenameDomain
      summary: Unbind the custom domain from this site
      description: Clears the domain binding and removes the serving symlink. Owner only (API key).
      tags: [Custom domains]
      security: [{ apiKey: [] }]
      parameters: [ { $ref: '#/components/parameters/sitename' } ]
      responses:
        '204': { description: Domain unbound }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/templates:
    get:
      operationId: getTemplates
      summary: List starter templates
      description: Public. Returns id + title + description for each template.
      tags: [Templates]
      responses:
        '200':
          description: Templates
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/TemplateMeta' } }

  /v1/templates/{id}:
    get:
      operationId: getTemplatesById
      summary: Get a template's deployable files
      description: Public. Returns the template's `files` map, ready to POST to `/v1/sites/{name}/files`.
      tags: [Templates]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, example: landing }
      responses:
        '200':
          description: Template with files
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: '#/components/schemas/TemplateMeta' }
                  - type: object
                    properties: { files: { type: object, additionalProperties: { type: string } } }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/admin/users:
    get:
      operationId: getAdminUsers
      summary: List all users + their sites (admin)
      description: >-
        Admin-only. Returns every registered user with their sites nested (API keys
        are never included). Powers the /admin dashboard. A signed-in non-admin gets
        404, not 403 — the endpoint does not confirm it exists to anyone who may not
        use it, and /admin renders that as an ordinary not-found page.
      tags: [Admin]
      security: [{ apiKey: [] }]
      responses:
        '200':
          description: All users with nested sites and totals.
          content:
            application/json:
              schema:
                type: object
                properties:
                  user_count: { type: integer }
                  site_count: { type: integer }
                  users:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        username: { type: string }
                        handle: { type: string }
                        is_admin: { type: boolean }
                        created_at: { type: string, format: date-time }
                        site_count: { type: integer }
                        sites: { type: array, items: { type: object } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: not an admin (deliberately indistinguishable from a route that does not exist) }

  /v1/transcribe:
    post:
      operationId: postTranscribe
      summary: Transcribe a voice recording to text
      description: >-
        Powers the mic button in the builder chat. POST the raw audio bytes as
        the body with the recording's Content-Type — WebM/Opus (Chrome) and
        MP4/AAC (iOS Safari) are both accepted, since browsers disagree and the
        server sniffs the container itself. Speech-to-text runs on this box
        (Moonshine, CPU-only), so audio is never sent to a third party. Returns
        the text for the user to edit and send; it does not start a build.
        Sign-in-gated and rate limited. Absent when the server has no
        TRANSCRIBE_URL configured.
      tags: [AI]
      security: [{ apiKey: [] }]
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema: { type: string, format: binary }
      responses:
        '200':
          description: Transcript. Empty text means no speech was detected.
          content:
            application/json:
              schema:
                type: object
                properties:
                  text: { type: string }
                  seconds: { type: number, description: duration of the decoded audio }
                  took_ms: { type: integer }
        '400': { description: no audio, or the container could not be decoded }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '413': { description: recording too large (25 MB cap) }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /v1/transcribe/ticket:
    post:
      operationId: postTranscribeTicket
      summary: Mint a short-lived ticket for live transcription
      description: >-
        Returns a 60-second signed ticket for the live speech WebSocket at
        `/v1/transcribe/stream`. A browser cannot set headers on a WebSocket
        handshake, so the API key cannot travel the usual way; putting it in the
        URL would land it in access logs and history. The ticket is HMAC-signed,
        carries only the user id and an expiry, and the speech service verifies
        it without ever seeing a key. Absent when the server has no
        TRANSCRIBE_TICKET_SECRET configured.
      tags: [AI]
      security: [{ apiKey: [] }]
      responses:
        '200':
          description: A ticket to use as the `t` query parameter on the WebSocket.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ticket: { type: string }
                  expires_in: { type: integer, description: seconds until it expires }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: live transcription is not configured }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /v1/generate:
    post:
      operationId: postGenerate
      summary: Generate a site from a prompt (AI create)
      description: >-
        Powers the home-page "create with AI" chat. Sign-in-gated and rate-limited.
        Runs on a single model backend (the operator's Grok subscription, via a
        local sidecar) with no fallback providers. Send the conversation and
        (optionally) the current HTML and attachments; returns a short reply and,
        when ready, the HTML. Disabled if no model backend is configured.
      tags: [AI]
      security: [{ apiKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                messages:
                  type: array
                  items:
                    type: object
                    properties: { role: { type: string, enum: [user, assistant] }, content: { type: string } }
                html: { type: string, description: "current site HTML, for incremental edits" }
                attachments:
                  type: array
                  description: images / PDFs / text on the latest user turn
                  items:
                    type: object
                    properties:
                      kind: { type: string, enum: [image, document, text] }
                      mediaType: { type: string }
                      name: { type: string }
                      data: { type: string, description: base64 (image/document) }
                      text: { type: string }
      responses:
        '200':
          description: >-
            Returns a jobId; poll /v1/generate/status until it reports done.
            Every backend answers this way. A full build takes a minute or more,
            which is far too long to hold one HTTP response open — browsers drop
            an idle connection long before it finishes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobId: { type: string, description: poll /v1/generate/status with this }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503':
          description: too many builds already running; retry shortly

  /v1/generate/status:
    get:
      operationId: getGenerateStatus
      summary: Poll an async AI-create job
      description: >-
        Poll the status of a job started by POST /v1/generate. Returns running
        until the build finishes, then done with the reply and HTML (or error
        with a message). Poll every couple of seconds. Sign-in-gated, and a job
        is visible only to the user who started it — anyone else gets 404, as
        does an id that has expired. Jobs are kept 10 minutes after finishing.
      tags: [AI]
      security: [{ apiKey: [] }]
      parameters:
        - { name: id, in: query, required: true, schema: { type: string }, description: jobId from POST /v1/generate }
      responses:
        '200':
          description: Job status
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [running, done, error] }
                  reply: { type: string }
                  html: { type: string }
                  error: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Unknown or expired job }

  /healthz:
    get:
      operationId: getHealthz
      summary: Liveness probe
      tags: [Health]
      responses:
        '200': { description: Alive, content: { application/json: { schema: { type: object, properties: { status: { type: string, example: ok } } } } } }

  /readyz:
    get:
      operationId: getReadyz
      summary: Readiness probe (DB connected)
      tags: [Health]
      responses:
        '200': { description: Ready, content: { application/json: { schema: { type: object, properties: { status: { type: string, example: ok } } } } } }
        '503': { description: Not ready }

components:
  securitySchemes:
    apiKey: { type: apiKey, in: header, name: X-API-Key }

  parameters:
    sitename: { name: sitename, in: path, required: true, schema: { type: string }, example: my-cool-site }
    coll: { name: coll, in: path, required: true, schema: { type: string }, example: signups }
    csrf: { name: X-SH-CSRF, in: header, required: false, schema: { type: string, enum: ['1'] }, description: Required on cookie-authenticated writes }

  schemas:
    AuthResponse:
      type: object
      properties:
        id: { type: string, format: uuid }
        username: { type: string }
        api_key: { type: string }
        is_admin: { type: boolean }
        created: { type: boolean, description: true if this verify created the account }

    FilesBody:
      type: object
      required: [files]
      properties:
        files:
          type: object
          additionalProperties: { type: string }
          description: relative path -> file contents (text). `index.html` required.
      example:
        files:
          index.html: '<!DOCTYPE html><h1>Hello</h1>'
          css/style.css: 'body{font-family:sans-serif}'

    Site:
      type: object
      properties:
        id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        name: { type: string }
        active_version: { type: integer }
        site_url: { type: string, nullable: true, example: https://my-cool-site.simple-host.app }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    SiteWithNote:
      allOf:
        - { $ref: '#/components/schemas/Site' }
        - type: object
          properties: { note: { type: string } }

    TemplateMeta:
      type: object
      properties:
        id: { type: string, example: landing }
        title: { type: string }
        description: { type: string }

    Error:
      type: object
      properties: { error: { type: string } }

  responses:
    BadRequest: { description: Invalid request, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    Unauthorized: { description: "Missing or invalid API key — send the X-API-Key header, not Authorization Bearer", content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    Forbidden: { description: Admin access required, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    NotFound: { description: Not found, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    Conflict: { description: Site name already exists, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    TooLarge: { description: Payload too large, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    TooManyRequests: { description: Rate limited, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    OriginMismatch: { description: Origin/Referer does not match the site (or the site is locked), content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    VisitorAuthRequired:
      description: Visitor sign-in required to write (WRITE_AUTH_MODE=on)
      content:
        application/json:
          schema:
            type: object
            required: [error, code, sign_in, retry]
            properties:
              error: { type: string, example: sign-in required to write }
              code: { type: string, example: visitor_auth_required }
              sign_in: { type: string, example: /v1/auth/oauth/providers }
              retry: { type: boolean, example: true }
          example:
            error: sign-in required to write
            code: visitor_auth_required
            sign_in: /v1/auth/oauth/providers
            retry: true
