{
  "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 \u2014 inline JSON files (LLM-friendly) or a tar.gz/zip archive \u2014 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 \u2014 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 \u2014 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 \u2014 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 \u2014 callback hands off via `/?token=`) or a real content-host path (`/{handle}/{sitename}/\u2026`) / DNS-proven custom domain (site purpose \u2014 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=\u2026`. Owner purpose 302s to `{PUBLIC_BASE_URL}/?token=\u2026` 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 \u2014 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 \u2026`) 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 \u2014 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 \u2014 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 \u2192 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 \u2014 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 (\u2264 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 \u2014 `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 \u2264 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 \u2014 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 (\u2264 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 \u2014 `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 \u2264 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 \u2014 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}/\u2026` 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 \u2014 so a page hosted anywhere (GitHub Pages, Netlify, \u2026) 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 \u2192 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 \u2014 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 \u2014 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": null
          },
          "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 \u2014 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 \u2014 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 \u2014 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
            }
          }
        }
      }
    }
  }
}