{
  "openapi": "3.0.3",
  "info": {
    "title": "Simple Host API",
    "description": "Static website hosting with a light per-site backend. Deploy a site with one API call — inline JSON files (LLM-friendly) or a tar.gz/zip archive — and each site gets shared JSON state (with atomic ops), append-only collections. Sign-in is a 6-digit email code or Google 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 on a bound custom domain — never an API key. On the shared host pages save without sign-in or a key; on a custom domain saves need a visitor session or any account's X-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 or email-code sign-in, only on a bound custom domain. 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 open on the shared host, and on a custom domain need a visitor session or any account key). Once a site has a custom domain bound it lives only there — its shared-host page URL `https://sites.simple-host.app/{handle}/{site}/...` answers 302 to `https://<domain>/...` (same path and query) and its shared-host API takes no writes, key or not; agents write through the apex."
    },
    {
      "name": "Collections",
      "description": "Append-only per-site lists (POST is a write)"
    },
    {
      "name": "Custom domains",
      "description": "Bind one custom domain per site (CNAME or A record; certificate issued by the operator)"
    },
    {
      "name": "Analytics",
      "description": "Per-site visitor analytics (server-side, owner-scoped)"
    },
    {
      "name": "AI",
      "description": "Generate a site from a prompt"
    },
    {
      "name": "Health"
    }
  ],
  "paths": {
    "/v1/skills": {
      "get": {
        "operationId": "listSkills",
        "summary": "Skills hub catalog — discover the bundled agent skills",
        "description": "Public catalog of the skills this server bundles (name + description + fetch URL), so any agent can search them over HTTP. No auth.",
        "responses": {
          "200": {
            "description": "Skill catalog",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "plugin": {
                      "type": "string"
                    },
                    "version": {
                      "type": "string"
                    },
                    "count": {
                      "type": "integer"
                    },
                    "skills": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "name": {
                            "type": "string"
                          },
                          "description": {
                            "type": "string"
                          },
                          "url": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/skills/{name}": {
      "get": {
        "operationId": "getSkill",
        "summary": "Fetch one skill's SKILL.md (markdown)",
        "description": "Returns the raw SKILL.md for the named skill. Public, no auth.",
        "parameters": [
          {
            "name": "name",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The skill's SKILL.md",
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "404": {
            "description": "No such skill"
          }
        }
      }
    },
    "/v1/skills/{name}/references/{file}": {
      "get": {
        "operationId": "getSkillReference",
        "summary": "Fetch one of a skill's reference documents (markdown)",
        "description": "A skill's SKILL.md routes to reference documents under its references/ directory. A folder install has them on disk; install methods that fetch only SKILL.md by URL do not, so SKILL.md cites each reference by absolute URL as well and this endpoint serves it. Public, no auth.",
        "parameters": [
          {
            "name": "name",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "file",
            "in": "path",
            "required": true,
            "description": "The reference filename, e.g. backend.md",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The reference document",
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "description": "Invalid skill or reference name"
          },
          "404": {
            "description": "No such reference"
          }
        }
      }
    },
    "/v1/auth": {
      "post": {
        "operationId": "postAuth",
        "summary": "Request a sign-in code",
        "description": "Emails a 6-digit code (and a magic link) to the address. No user row is created here — the account is created lazily on the first successful verify, so this can't be used to enumerate users.",
        "tags": [
          "Auth"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email",
                    "example": "you@example.com"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Code sent",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string"
                    },
                    "email": {
                      "type": "string"
                    },
                    "expires_in_seconds": {
                      "type": "integer",
                      "example": 900
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/v1/auth/verify": {
      "post": {
        "operationId": "postAuthVerify",
        "summary": "Verify the code and get an API key",
        "description": "Exchange `{email, code}` (CLI/agent) or a magic-link `{token}` (browser) for an API key. The first successful email-code verify creates the account. Dashboard Google also redeems through this endpoint (`/?token=`). Visitor-issued codes are not accepted here. 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": "Google (more providers later). Public discovery for pages and the dashboard. Returns the enabled provider names (`google`) so pages never hardcode a provider. Empty when sign-in is not 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",
                    "email_enabled"
                  ],
                  "properties": {
                    "email_enabled": {
                      "type": "boolean",
                      "description": "Whether email sign-in codes are configured"
                    },
                    "providers": {
                      "type": "array",
                      "items": {
                        "type": "string",
                        "enum": [
                          "google"
                        ]
                      },
                      "example": [
                        "google"
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/auth/oauth/{provider}": {
      "get": {
        "operationId": "startOAuth",
        "summary": "Start OAuth",
        "description": "Apex start. Query `return_to` is required and must be an absolute URL. Two shapes: the dashboard origin `/` (owner purpose — callback hands off via `/?token=`) or a DNS-proven bound custom domain (site purpose — callback issues a host-and-site-scoped session). Success 302s to the provider with PKCE S256. Unknown or disabled provider is 404. No cookie is set here. The Shared content-host return_to URLs are rejected with 400 invalid return_to, as are apex URLs outside the dashboard. 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"
              ]
            }
          },
          {
            "name": "return_to",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uri"
            },
            "description": "Absolute URL of the page to return to after sign-in"
          }
        ],
        "responses": {
          "302": {
            "description": "Redirect to the identity provider",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string",
                  "format": "uri"
                }
              }
            }
          },
          "400": {
            "description": "Invalid or missing return_to",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "invalid return_to"
                }
              }
            }
          },
          "404": {
            "description": "Unknown or disabled provider",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "not found"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/v1/auth/oauth/{provider}/callback": {
      "get": {
        "operationId": "oauthCallback",
        "summary": "OAuth callback",
        "description": "Provider redirect URI on the apex. Consumes the one-time `state`. Identifies via userinfo, then `resolveUser` (link or create a `users` row on a verified email; refuse missing/unverified email). Site purpose 302s to `https://<return-host>/v1/visitor/establish?once=…`. Owner purpose 302s to `{PUBLIC_BASE_URL}/?token=…` for the dashboard to redeem via `POST /v1/auth/verify`. Neither redirect contains an API key. Failures are a short no-store HTML page on the apex with no Location and no cookie.",
        "tags": [
          "Visitor"
        ],
        "parameters": [
          {
            "name": "provider",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "google"
              ]
            }
          },
          {
            "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 or shared content host. 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": {
      "patch": {
        "operationId": "patchMe",
        "summary": "Edit display name or rename an empty account",
        "security": [
          {
            "apiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "minProperties": 1,
                "properties": {
                  "display_name": {
                    "type": "string",
                    "maxLength": 100,
                    "description": "Trimmed; empty clears the name. Never used in URLs."
                  },
                  "handle": {
                    "type": "string",
                    "pattern": "^[a-z0-9-]{1,39}$",
                    "description": "Must not be reserved; editable only with zero sites."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated account profile"
          },
          "400": {
            "description": "Invalid profile"
          },
          "401": {
            "description": "API key required"
          },
          "409": {
            "description": "Handle taken or address fixed once something is published"
          }
        }
      },
      "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"
                    },
                    "handle": {
                      "type": "string"
                    },
                    "display_name": {
                      "type": "string"
                    },
                    "is_admin": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/v1/me/api-key/rotate": {
      "post": {
        "operationId": "postMeApiKeyRotate",
        "summary": "Rotate your API key",
        "description": "Replaces the authenticated owner's API key and returns the new key. The old key stops working immediately.",
        "tags": [
          "Auth"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "API key rotated",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "api_key",
                    "message"
                  ],
                  "properties": {
                    "api_key": {
                      "type": "string"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/v1/sites": {
      "get": {
        "operationId": "getSites",
        "summary": "List your sites",
        "description": "Returns the authenticated user's sites. Admins see all sites.",
        "tags": [
          "Sites"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "List of sites",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Site"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/v1/sites/{sitename}/files": {
      "post": {
        "operationId": "postSitesBySitenameFiles",
        "summary": "Deploy a site from inline JSON files",
        "description": "The LLM-friendly deploy path — send every file inline in one request, no archiving. Text goes in `files`; base64-encoded binary goes in `files_base64`. `index.html` is required. Relative paths only (`..`/absolute are rejected); secret files (`.env`, `.git/*`, `id_rsa`) are dropped and script extensions (`.sh .py .php …`) are rejected. Use PUT to update an existing site.",
        "tags": [
          "Deploy"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FilesBody"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Site created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteWithNote"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      },
      "put": {
        "operationId": "putSitesBySitenameFiles",
        "summary": "Update a site from inline JSON files",
        "description": "Same as POST, but for an existing site you own. Creates a new version and activates it.",
        "tags": [
          "Deploy"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FilesBody"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Site updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteWithNote"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/sites/{sitename}": {
      "patch": {
        "operationId": "patchSitesBySitename",
        "summary": "Rename your site",
        "description": "Renames the database row and on-disk site directory. The new name uses the same validation and per-owner uniqueness rule as creation. A bound custom domain remains attached. The old public URL is not redirected and returns 404; the response states this explicitly.",
        "tags": [
          "Sites"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "example": "new-site-name"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Site renamed; includes old_url",
            "site_url": null,
            "and old_url_status": null
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      },
      "post": {
        "operationId": "postSitesBySitename",
        "summary": "Create a site from an archive",
        "description": "Upload a `.tar.gz` or `.zip` (for framework builds, binary assets, large sites). Max 100 MB.",
        "tags": [
          "Deploy"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/gzip": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            },
            "application/zip": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Site created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteWithNote"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          }
        }
      },
      "put": {
        "operationId": "putSitesBySitename",
        "summary": "Update a site from an archive",
        "description": "Upload a new version of an existing site you own. Creates a new version and activates it.",
        "tags": [
          "Deploy"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/gzip": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            },
            "application/zip": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Site updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteWithNote"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          }
        }
      },
      "delete": {
        "operationId": "deleteSitesBySitename",
        "summary": "Delete your site",
        "description": "Deletes the site you own — all versions and files from disk.",
        "tags": [
          "Sites"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "204": {
            "description": "Site deleted"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/sites/{sitename}/export.tar.gz": {
      "get": {
        "operationId": "exportSite",
        "summary": "Download a site as a tar.gz",
        "description": "Streams the site's published files, its saved JSON state and its collections as one archive. Exists because an event instance is destroyed when the event ends and nothing is backed up, so the person who built something can take it with them. The saved data comes first: the files usually exist somewhere else, the JSON only lives here.",
        "tags": [
          "Sites"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "name": "sitename",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A gzipped tar archive",
            "content": {
              "application/gzip": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "Not found",
            "or not yours": null
          }
        }
      }
    },
    "/v1/sites/{sitename}/versions": {
      "get": {
        "operationId": "getSitesBySitenameVersions",
        "summary": "List a site's versions",
        "description": "The versions this instance still holds, newest first. An instance may be configured to keep only the last few deploys, or only the live one, in which case older versions are absent here and from disk. KEEP_VERSIONS controls it; event boxes are installed with 1.",
        "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` = hidden from it. New sites are created `unlisted` — listing is an explicit act, not a side effect of deploying.\n\nThis controls LISTING ONLY. Both values are equally reachable by anyone with the URL; nothing on the content-serving path consults it. `unlisted` is not privacy and must never be described as such. 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/analytics/sites": {
      "get": {
        "operationId": "getAnalyticsSites",
        "summary": "Per-site traffic totals for every site you own",
        "description": "One call returning the class split for all of the caller's sites, ordered by `person.views` descending — the \"which of my sites are actually being read\" view. Exists so a dashboard can sort by traffic without one request per site.\n\nNot under `/v1/sites/...` because that would collide with a site named \"analytics\". Pass `all=1` (admins only) to widen the scope to every site on the instance; non-admins get 403 rather than a silently narrowed list.",
        "tags": [
          "Analytics"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "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
            }
          },
          {
            "name": "all",
            "in": "query",
            "required": false,
            "description": "Admins only — include every site on the instance.",
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Per-site totals, highest `person.views` first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "range_days": {
                      "type": "integer",
                      "example": 30
                    },
                    "sites": {
                      "type": "array",
                      "items": {
                        "allOf": [
                          {
                            "type": "object",
                            "properties": {
                              "name": {
                                "type": "string",
                                "example": "eb2-wait"
                              }
                            }
                          },
                          {
                            "$ref": "#/components/schemas/TrafficSplit"
                          }
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`all=1` requested by a non-admin"
          }
        }
      }
    },
    "/v1/sites/{sitename}/analytics": {
      "get": {
        "operationId": "getSitesBySitenameAnalytics",
        "summary": "Get per-site visitor analytics",
        "description": "Owner-scoped view and visitor counts derived server-side from the access log (no client beacon). Every bucket is split three ways by who was asking: `person` (no automation signature), `bot` (crawlers, AI scrapers, SEO tools, security scanners, HTTP libraries) and `infra` (uptime probes and health checks aimed at the site). Read `person` as the audience number — a total that folds in `infra` is dominated by monitoring, not people.\n\n`visitors` are unique visitors: COUNT(DISTINCT hashed IP) over the window asked for. The salt is stable, so `totals.*.visitors` is genuine uniques for the range and is NOT the sum of the `daily` figures — someone who visited on five days is one unique in `totals` and five in `daily`. IP-based, so a shared office NAT reads as one visitor and a phone switching wifi→cellular reads as two.\n\n`daily` is zero-filled dense for the requested window (oldest → newest). `hourly` is always 24 zero-filled buckets ending with the hour in progress. Only successful document responses (200/304, non-asset paths) count as views, for every class alike. 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
                    },
                    "classified_from": {
                      "type": "string",
                      "format": "date",
                      "example": "2026-08-09",
                      "description": "First UTC day for which the person/bot/infra split exists. Earlier days in `daily` report under `unknown`. Omitted when nothing has been classified yet."
                    },
                    "totals": {
                      "$ref": "#/components/schemas/TrafficSplit"
                    },
                    "last_24h": {
                      "$ref": "#/components/schemas/TrafficSplit"
                    },
                    "daily": {
                      "type": "array",
                      "items": {
                        "allOf": [
                          {
                            "type": "object",
                            "properties": {
                              "day": {
                                "type": "string",
                                "format": "date",
                                "example": "2026-08-31"
                              }
                            }
                          },
                          {
                            "$ref": "#/components/schemas/TrafficSplit"
                          }
                        ]
                      }
                    },
                    "hourly": {
                      "type": "array",
                      "description": "Exactly 24 buckets, oldest → newest.",
                      "items": {
                        "allOf": [
                          {
                            "type": "object",
                            "properties": {
                              "hour": {
                                "type": "string",
                                "format": "date-time",
                                "example": "2026-08-31T14:00:00Z"
                              }
                            }
                          },
                          {
                            "$ref": "#/components/schemas/TrafficSplit"
                          }
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/sites/{sitename}/analytics/geo": {
      "get": {
        "operationId": "getSitesBySitenameAnalyticsGeo",
        "summary": "Where a site's visitors are, by country",
        "description": "Per-country traffic for one site over the last `days` UTC days, split by the same `person` / `bot` / `infra` classes as the rest of analytics and ordered by `person.views` descending (ties broken by country code).\n\nCountry is resolved on the server from a local IP-range table refreshed from a public dataset — no visitor address is ever sent to a geolocation service, and the raw address is still never stored. Country data: \"IP Geolocation by DB-IP\" (https://db-ip.com), CC BY 4.0.\n\n`country` is ISO-3166 alpha-2. `\"XX\"` / `\"Unknown\"` is traffic the table could not place (an unlisted block, or a server whose ranges have not been loaded); it is listed rather than dropped, so the countries still add up to the site's totals.\n\n`visitors` is COUNT(DISTINCT hashed IP) over the whole window, exactly as in the main analytics endpoint — genuine uniques for the range, not the sum of the daily figures. Someone whose address changes country mid-range counts once in each, so the columns can total slightly more than the site's own figure. `unknown` is always zero here: the pre-classifier history has no addresses left to place. 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": "Per-country traffic, most real people first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "range_days": {
                      "type": "integer",
                      "example": 30
                    },
                    "countries": {
                      "type": "array",
                      "items": {
                        "allOf": [
                          {
                            "type": "object",
                            "properties": {
                              "country": {
                                "type": "string",
                                "example": "US",
                                "description": "ISO-3166 alpha-2, or \"XX\" when unresolved"
                              },
                              "country_name": {
                                "type": "string",
                                "example": "United States"
                              }
                            }
                          },
                          {
                            "$ref": "#/components/schemas/TrafficSplit"
                          }
                        ]
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/sites/{sitename}/me": {
      "get": {
        "operationId": "getVisitorMe",
        "summary": "Read the current site visitor session",
        "description": "On the shared content host returns signed_in false, sign_in_available false and code custom_domain_required without consulting cookies; when the site has a custom domain bound the code is use_custom_domain and `domain` names it, so the page can send the visitor to sign in there (that site saves only on its own domain). On custom domains, Origin/Referer-gated. Checks the host, site and session expiry without extending the session. No CSRF header required. Anonymous visitors receive 200.",
        "tags": [
          "State"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "200": {
            "description": "Signed-in identity or anonymous visitor",
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "private, no-store"
                  ]
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "type": "object",
                      "required": [
                        "signed_in",
                        "expires_at"
                      ],
                      "properties": {
                        "signed_in": {
                          "type": "boolean",
                          "enum": [
                            true
                          ]
                        },
                        "email": {
                          "type": "string",
                          "description": "On a custom domain. Latest verified OAuth identity email; users.username when no identity exists"
                        },
                        "provider": {
                          "type": "string",
                          "example": "google",
                          "description": "Google (more providers later); omitted when no OAuth identity exists"
                        },
                        "expires_at": {
                          "type": "string",
                          "format": "date-time",
                          "description": "Earlier of absolute and idle expiry (RFC3339)"
                        }
                      }
                    },
                    {
                      "type": "object",
                      "required": [
                        "signed_in",
                        "sign_in"
                      ],
                      "properties": {
                        "signed_in": {
                          "type": "boolean",
                          "enum": [
                            false
                          ]
                        },
                        "sign_in": {
                          "type": "string",
                          "enum": [
                            "/v1/auth/oauth/providers"
                          ]
                        }
                      }
                    },
                    {
                      "type": "object",
                      "required": [
                        "signed_in",
                        "sign_in_available",
                        "code"
                      ],
                      "properties": {
                        "signed_in": {
                          "type": "boolean",
                          "enum": [
                            false
                          ]
                        },
                        "sign_in_available": {
                          "type": "boolean",
                          "enum": [
                            false
                          ]
                        },
                        "code": {
                          "type": "string",
                          "enum": [
                            "custom_domain_required",
                            "use_custom_domain"
                          ]
                        },
                        "domain": {
                          "type": "string",
                          "description": "The site's bound custom domain; present only with code use_custom_domain"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "description": "Internal server error"
          }
        }
      },
      "options": {
        "operationId": "optionsVisitorMe",
        "summary": "Preflight for visitor session reads",
        "tags": [
          "State"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "204": {
            "description": "Origin allowed",
            "headers": {
              "Access-Control-Allow-Methods": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "GET, OPTIONS"
                  ]
                }
              },
              "Access-Control-Allow-Headers": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "Content-Type"
                  ]
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          }
        }
      }
    },
    "/v1/sites/{sitename}/state": {
      "get": {
        "operationId": "getSitesBySitenameState",
        "summary": "Read per-site JSON state",
        "description": "Returns the JSON blob for this site (`null` if unset). PUBLIC store — no API key; the server checks `Origin`/`Referer` and only accepts calls from the site's own page (`https://{sitename}.simple-host.app`). The response carries an `ETag`; send `If-None-Match: <etag>` to get a `304` when nothing changed (cheap polling). Never store secrets here.",
        "tags": [
          "State"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "name": "If-None-Match",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Current state (arbitrary JSON, `null` if unset)",
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {}
              }
            }
          },
          "304": {
            "description": "Not modified (matched If-None-Match)"
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          }
        }
      },
      "put": {
        "operationId": "putSitesBySitenameState",
        "summary": "Replace per-site JSON state",
        "description": "Overwrites the whole blob (≤ 1 MB), last-write-wins. Optionally pass `If-Match: <etag>` for optimistic concurrency (returns 412 on conflict). Origin-gated. On the shared content host (sites.simple-host.app) writes are open: any page can save without sign-in or key, and the data can be changed by anyone. On a site's bound custom domain writes need a visitor session (`X-SH-CSRF: 1`) or any account's `X-API-Key`; a key is accepted anywhere except on the shared host of a site that has a domain bound, which takes no writes at all (401 use_custom_domain). WRITE_AUTH_MODE=log/off and allow_anonymous_writes retain their compatibility exceptions. Invalid keys return 401 with {error: \"invalid API key\", code: \"invalid_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": {
            "description": "Sign-in required on a custom domain (visitor_auth_required), invalid account API key (invalid_api_key), or a shared-host write to a site that has a custom domain bound (use_custom_domain, with the domain in `domain`) — such a site lives only on its own domain, and its shared-host API takes no writes for it, key or not; use the apex (`https://simple-host.app/v1/...`, key) or the domain's own `/v1/` (key or session). Reads on the shared host stay public. Never returned for a site that has no domain.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    },
                    "sign_in": {
                      "type": "string"
                    },
                    "retry": {
                      "type": "boolean"
                    },
                    "domain": {
                      "type": "string",
                      "description": "The site's bound custom domain (with code use_custom_domain)"
                    }
                  }
                },
                "examples": {
                  "invalidKey": {
                    "value": {
                      "error": "invalid API key",
                      "code": "invalid_api_key"
                    }
                  },
                  "signIn": {
                    "value": {
                      "error": "sign-in required to write",
                      "code": "visitor_auth_required",
                      "sign_in": "/v1/auth/oauth/providers",
                      "retry": true
                    }
                  },
                  "useCustomDomain": {
                    "value": {
                      "error": "this site saves on its own domain",
                      "code": "use_custom_domain",
                      "domain": "recipes.brand.com"
                    }
                  }
                }
              }
            }
          },
          "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. On the shared content host (sites.simple-host.app) writes are open: any page can save without sign-in or key, and the data can be changed by anyone. On a site's bound custom domain writes need a visitor session (`X-SH-CSRF: 1`) or any account's `X-API-Key`; a key is accepted anywhere except on the shared host of a site that has a domain bound, which takes no writes at all (401 use_custom_domain). WRITE_AUTH_MODE=log/off and allow_anonymous_writes retain their compatibility exceptions. Invalid keys return 401 with {error: \"invalid API key\", code: \"invalid_api_key\"}. Ops — `set` (path,value), `inc` (path,by), `append` (path,value), `remove` (path), `removeWhere` (path,match).",
        "tags": [
          "State"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/csrf"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "ops"
                ],
                "properties": {
                  "ops": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "op": {
                          "type": "string",
                          "enum": [
                            "set",
                            "inc",
                            "append",
                            "remove",
                            "removeWhere"
                          ]
                        },
                        "path": {
                          "type": "string",
                          "example": "counters.visits"
                        },
                        "value": {},
                        "by": {
                          "type": "number"
                        },
                        "match": {
                          "type": "object"
                        }
                      }
                    }
                  }
                },
                "example": {
                  "ops": [
                    {
                      "op": "inc",
                      "path": "count",
                      "by": 1
                    },
                    {
                      "op": "append",
                      "path": "items",
                      "value": {
                        "id": "x"
                      }
                    }
                  ]
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "New state",
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {}
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "description": "Sign-in required on a custom domain (visitor_auth_required), invalid account API key (invalid_api_key), or a shared-host write to a site that has a custom domain bound (use_custom_domain, with the domain in `domain`) — such a site lives only on its own domain, and its shared-host API takes no writes for it, key or not; use the apex (`https://simple-host.app/v1/...`, key) or the domain's own `/v1/` (key or session). Reads on the shared host stay public. Never returned for a site that has no domain.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    },
                    "sign_in": {
                      "type": "string"
                    },
                    "retry": {
                      "type": "boolean"
                    },
                    "domain": {
                      "type": "string",
                      "description": "The site's bound custom domain (with code use_custom_domain)"
                    }
                  }
                },
                "examples": {
                  "invalidKey": {
                    "value": {
                      "error": "invalid API key",
                      "code": "invalid_api_key"
                    }
                  },
                  "signIn": {
                    "value": {
                      "error": "sign-in required to write",
                      "code": "visitor_auth_required",
                      "sign_in": "/v1/auth/oauth/providers",
                      "retry": true
                    }
                  },
                  "useCustomDomain": {
                    "value": {
                      "error": "this site saves on its own domain",
                      "code": "use_custom_domain",
                      "domain": "recipes.brand.com"
                    }
                  }
                }
              }
            }
          },
          "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. On the shared content host (sites.simple-host.app) writes are open: any page can save without sign-in or key, and the data can be changed by anyone. On a site's bound custom domain writes need a visitor session (`X-SH-CSRF: 1`) or any account's `X-API-Key`; a key is accepted anywhere except on the shared host of a site that has a domain bound, which takes no writes at all (401 use_custom_domain). WRITE_AUTH_MODE=log/off and allow_anonymous_writes retain their compatibility exceptions. Invalid keys return 401 with {error: \"invalid API key\", code: \"invalid_api_key\"}. Each item ≤ 64 KB.",
        "tags": [
          "Collections"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/coll"
          },
          {
            "$ref": "#/components/parameters/csrf"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Item appended"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "description": "Sign-in required on a custom domain (visitor_auth_required), invalid account API key (invalid_api_key), or a shared-host write to a site that has a custom domain bound (use_custom_domain, with the domain in `domain`) — such a site lives only on its own domain, and its shared-host API takes no writes for it, key or not; use the apex (`https://simple-host.app/v1/...`, key) or the domain's own `/v1/` (key or session). Reads on the shared host stay public. Never returned for a site that has no domain.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    },
                    "sign_in": {
                      "type": "string"
                    },
                    "retry": {
                      "type": "boolean"
                    },
                    "domain": {
                      "type": "string",
                      "description": "The site's bound custom domain (with code use_custom_domain)"
                    }
                  }
                },
                "examples": {
                  "invalidKey": {
                    "value": {
                      "error": "invalid API key",
                      "code": "invalid_api_key"
                    }
                  },
                  "signIn": {
                    "value": {
                      "error": "sign-in required to write",
                      "code": "visitor_auth_required",
                      "sign_in": "/v1/auth/oauth/providers",
                      "retry": true
                    }
                  },
                  "useCustomDomain": {
                    "value": {
                      "error": "this site saves on its own domain",
                      "code": "use_custom_domain",
                      "domain": "recipes.brand.com"
                    }
                  }
                }
              }
            }
          },
          "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) are open on the shared host; on a custom domain they need a visitor session with X-SH-CSRF or any valid account X-API-Key (a key is accepted anywhere except on the shared host of a site that has a domain bound, which takes no writes at all — 401 use_custom_domain). Origin gating still applies.",
        "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}/me": {
      "get": {
        "operationId": "getVisitorMeByHandle",
        "summary": "Read the current site visitor session",
        "description": "On the shared content host returns signed_in false, sign_in_available false and code custom_domain_required without consulting cookies; when the site has a custom domain bound the code is use_custom_domain and `domain` names it, so the page can send the visitor to sign in there (that site saves only on its own domain). On custom domains, Origin/Referer-gated. Checks the host, site and session expiry without extending the session. No CSRF header required. Anonymous visitors receive 200.",
        "tags": [
          "State"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "name": "handle",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Signed-in identity or anonymous visitor",
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "private, no-store"
                  ]
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "type": "object",
                      "required": [
                        "signed_in",
                        "expires_at"
                      ],
                      "properties": {
                        "signed_in": {
                          "type": "boolean",
                          "enum": [
                            true
                          ]
                        },
                        "email": {
                          "type": "string",
                          "description": "On a custom domain. Latest verified OAuth identity email; users.username when no identity exists"
                        },
                        "provider": {
                          "type": "string",
                          "example": "google",
                          "description": "Google (more providers later); omitted when no OAuth identity exists"
                        },
                        "expires_at": {
                          "type": "string",
                          "format": "date-time",
                          "description": "Earlier of absolute and idle expiry (RFC3339)"
                        }
                      }
                    },
                    {
                      "type": "object",
                      "required": [
                        "signed_in",
                        "sign_in"
                      ],
                      "properties": {
                        "signed_in": {
                          "type": "boolean",
                          "enum": [
                            false
                          ]
                        },
                        "sign_in": {
                          "type": "string",
                          "enum": [
                            "/v1/auth/oauth/providers"
                          ]
                        }
                      }
                    },
                    {
                      "type": "object",
                      "required": [
                        "signed_in",
                        "sign_in_available",
                        "code"
                      ],
                      "properties": {
                        "signed_in": {
                          "type": "boolean",
                          "enum": [
                            false
                          ]
                        },
                        "sign_in_available": {
                          "type": "boolean",
                          "enum": [
                            false
                          ]
                        },
                        "code": {
                          "type": "string",
                          "enum": [
                            "custom_domain_required",
                            "use_custom_domain"
                          ]
                        },
                        "domain": {
                          "type": "string",
                          "description": "The site's bound custom domain; present only with code use_custom_domain"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "description": "Internal server error"
          }
        }
      },
      "options": {
        "operationId": "optionsVisitorMeByHandle",
        "summary": "Preflight for visitor session reads",
        "tags": [
          "State"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "name": "handle",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Origin allowed",
            "headers": {
              "Access-Control-Allow-Methods": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "GET, OPTIONS"
                  ]
                }
              },
              "Access-Control-Allow-Headers": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "Content-Type"
                  ]
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          }
        }
      }
    },
    "/v1/u/{handle}/sites/{sitename}/state": {
      "get": {
        "operationId": "getSiteStateByHandle",
        "summary": "Read per-site JSON state (v3, by handle)",
        "description": "Returns the JSON blob for this site (`null` if unset). PUBLIC store — no API key; the server checks `Origin`/`Referer` and only accepts calls from the site's own page (`https://{sitename}.simple-host.app`). The response carries an `ETag`; send `If-None-Match: <etag>` to get a `304` when nothing changed (cheap polling). Never store secrets here. v3 user-scoped, path-model-canonical variant: resolves the site by (handle -> user_id) + (user_id, name).",
        "tags": [
          "State"
        ],
        "parameters": [
          {
            "name": "handle",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "the site owner's URL-safe handle"
          },
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "name": "If-None-Match",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Current state (arbitrary JSON, `null` if unset)",
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {}
              }
            }
          },
          "304": {
            "description": "Not modified (matched If-None-Match)"
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          }
        }
      },
      "put": {
        "operationId": "putSiteStateByHandle",
        "summary": "Replace per-site JSON state (v3, by handle)",
        "description": "Overwrites the whole blob (≤ 1 MB), last-write-wins. Optionally pass `If-Match: <etag>` for optimistic concurrency (returns 412 on conflict). Origin-gated. On the shared content host (sites.simple-host.app) writes are open: any page can save without sign-in or key, and the data can be changed by anyone. On a site's bound custom domain writes need a visitor session (`X-SH-CSRF: 1`) or any account's `X-API-Key`; a key is accepted anywhere except on the shared host of a site that has a domain bound, which takes no writes at all (401 use_custom_domain). WRITE_AUTH_MODE=log/off and allow_anonymous_writes retain their compatibility exceptions. Invalid keys return 401 with {error: \"invalid API key\", code: \"invalid_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": {
            "description": "Sign-in required on a custom domain (visitor_auth_required), invalid account API key (invalid_api_key), or a shared-host write to a site that has a custom domain bound (use_custom_domain, with the domain in `domain`) — such a site lives only on its own domain, and its shared-host API takes no writes for it, key or not; use the apex (`https://simple-host.app/v1/...`, key) or the domain's own `/v1/` (key or session). Reads on the shared host stay public. Never returned for a site that has no domain.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    },
                    "sign_in": {
                      "type": "string"
                    },
                    "retry": {
                      "type": "boolean"
                    },
                    "domain": {
                      "type": "string",
                      "description": "The site's bound custom domain (with code use_custom_domain)"
                    }
                  }
                },
                "examples": {
                  "invalidKey": {
                    "value": {
                      "error": "invalid API key",
                      "code": "invalid_api_key"
                    }
                  },
                  "signIn": {
                    "value": {
                      "error": "sign-in required to write",
                      "code": "visitor_auth_required",
                      "sign_in": "/v1/auth/oauth/providers",
                      "retry": true
                    }
                  },
                  "useCustomDomain": {
                    "value": {
                      "error": "this site saves on its own domain",
                      "code": "use_custom_domain",
                      "domain": "recipes.brand.com"
                    }
                  }
                }
              }
            }
          },
          "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. On the shared content host (sites.simple-host.app) writes are open: any page can save without sign-in or key, and the data can be changed by anyone. On a site's bound custom domain writes need a visitor session (`X-SH-CSRF: 1`) or any account's `X-API-Key`; a key is accepted anywhere except on the shared host of a site that has a domain bound, which takes no writes at all (401 use_custom_domain). WRITE_AUTH_MODE=log/off and allow_anonymous_writes retain their compatibility exceptions. Invalid keys return 401 with {error: \"invalid API key\", code: \"invalid_api_key\"}. Ops — `set` (path,value), `inc` (path,by), `append` (path,value), `remove` (path), `removeWhere` (path,match). v3 user-scoped, path-model-canonical variant: resolves the site by (handle -> user_id) + (user_id, name).",
        "tags": [
          "State"
        ],
        "parameters": [
          {
            "name": "handle",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "the site owner's URL-safe handle"
          },
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/csrf"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "ops"
                ],
                "properties": {
                  "ops": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "op": {
                          "type": "string",
                          "enum": [
                            "set",
                            "inc",
                            "append",
                            "remove",
                            "removeWhere"
                          ]
                        },
                        "path": {
                          "type": "string",
                          "example": "counters.visits"
                        },
                        "value": {},
                        "by": {
                          "type": "number"
                        },
                        "match": {
                          "type": "object"
                        }
                      }
                    }
                  }
                },
                "example": {
                  "ops": [
                    {
                      "op": "inc",
                      "path": "count",
                      "by": 1
                    },
                    {
                      "op": "append",
                      "path": "items",
                      "value": {
                        "id": "x"
                      }
                    }
                  ]
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "New state",
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {}
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "description": "Sign-in required on a custom domain (visitor_auth_required), invalid account API key (invalid_api_key), or a shared-host write to a site that has a custom domain bound (use_custom_domain, with the domain in `domain`) — such a site lives only on its own domain, and its shared-host API takes no writes for it, key or not; use the apex (`https://simple-host.app/v1/...`, key) or the domain's own `/v1/` (key or session). Reads on the shared host stay public. Never returned for a site that has no domain.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    },
                    "sign_in": {
                      "type": "string"
                    },
                    "retry": {
                      "type": "boolean"
                    },
                    "domain": {
                      "type": "string",
                      "description": "The site's bound custom domain (with code use_custom_domain)"
                    }
                  }
                },
                "examples": {
                  "invalidKey": {
                    "value": {
                      "error": "invalid API key",
                      "code": "invalid_api_key"
                    }
                  },
                  "signIn": {
                    "value": {
                      "error": "sign-in required to write",
                      "code": "visitor_auth_required",
                      "sign_in": "/v1/auth/oauth/providers",
                      "retry": true
                    }
                  },
                  "useCustomDomain": {
                    "value": {
                      "error": "this site saves on its own domain",
                      "code": "use_custom_domain",
                      "domain": "recipes.brand.com"
                    }
                  }
                }
              }
            }
          },
          "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. On the shared content host (sites.simple-host.app) writes are open: any page can save without sign-in or key, and the data can be changed by anyone. On a site's bound custom domain writes need a visitor session (`X-SH-CSRF: 1`) or any account's `X-API-Key`; a key is accepted anywhere except on the shared host of a site that has a domain bound, which takes no writes at all (401 use_custom_domain). WRITE_AUTH_MODE=log/off and allow_anonymous_writes retain their compatibility exceptions. Invalid keys return 401 with {error: \"invalid API key\", code: \"invalid_api_key\"}. Each item ≤ 64 KB. v3 user-scoped, path-model-canonical variant: resolves the site by (handle -> user_id) + (user_id, name).",
        "tags": [
          "Collections"
        ],
        "parameters": [
          {
            "name": "handle",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "the site owner's URL-safe handle"
          },
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/coll"
          },
          {
            "$ref": "#/components/parameters/csrf"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Item appended"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "description": "Sign-in required on a custom domain (visitor_auth_required), invalid account API key (invalid_api_key), or a shared-host write to a site that has a custom domain bound (use_custom_domain, with the domain in `domain`) — such a site lives only on its own domain, and its shared-host API takes no writes for it, key or not; use the apex (`https://simple-host.app/v1/...`, key) or the domain's own `/v1/` (key or session). Reads on the shared host stay public. Never returned for a site that has no domain.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    },
                    "sign_in": {
                      "type": "string"
                    },
                    "retry": {
                      "type": "boolean"
                    },
                    "domain": {
                      "type": "string",
                      "description": "The site's bound custom domain (with code use_custom_domain)"
                    }
                  }
                },
                "examples": {
                  "invalidKey": {
                    "value": {
                      "error": "invalid API key",
                      "code": "invalid_api_key"
                    }
                  },
                  "signIn": {
                    "value": {
                      "error": "sign-in required to write",
                      "code": "visitor_auth_required",
                      "sign_in": "/v1/auth/oauth/providers",
                      "retry": true
                    }
                  },
                  "useCustomDomain": {
                    "value": {
                      "error": "this site saves on its own domain",
                      "code": "use_custom_domain",
                      "domain": "recipes.brand.com"
                    }
                  }
                }
              }
            }
          },
          "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) are open on the shared host; on a custom domain they need a visitor session with X-SH-CSRF or any valid account X-API-Key (a key is accepted anywhere except on the shared host of a site that has a domain bound, which takes no writes at all — 401 use_custom_domain). Origin gating still applies. v3 user-scoped, path-model-canonical variant: resolves the site by (handle -> user_id) + (user_id, name).",
        "tags": [
          "Collections"
        ],
        "parameters": [
          {
            "name": "handle",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "the site owner's URL-safe handle"
          },
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/coll"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 50,
              "maximum": 200
            }
          },
          {
            "name": "before",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "cursor for the next page"
          }
        ],
        "responses": {
          "200": {
            "description": "Items",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "items": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          }
        }
      }
    },
    "/v1/sites/{sitename}/allow-anonymous-writes": {
      "put": {
        "operationId": "putAllowAnonymousWrites",
        "summary": "Admin override — allow anonymous writes on one site",
        "description": "Sets `sites.allow_anonymous_writes`. Admin (`ADMIN_API_KEY`) only. Default is false. When `WRITE_AUTH_MODE=on` and this flag is true, anonymous writes to this site succeed and are logged with `outcome=overridden`. No `/v1/u/{handle}/…` twin.",
        "tags": [
          "Sites"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "allow"
                ],
                "properties": {
                  "allow": {
                    "type": "boolean"
                  }
                }
              },
              "example": {
                "allow": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Flag updated",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site": {
                      "type": "string"
                    },
                    "allow_anonymous_writes": {
                      "type": "boolean"
                    }
                  }
                },
                "example": {
                  "site": "wedding",
                  "allow_anonymous_writes": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/sites/{sitename}/allowed-origins": {
      "put": {
        "operationId": "putSitesBySitenameAllowedOrigins",
        "summary": "Allow external origins to use this site's backend",
        "description": "Whitelist extra origins (scheme://host, no path) that may call this site's state/collections API cross-origin — so a page hosted anywhere (external hosting, Netlify, …) can use the site as its backend for comments, feedback, counters, and forms. Replaces the whole list (max 20). Owner only. Same Origin-gated trust model as same-site calls: data stays public to the page's audience; never store secrets.",
        "tags": [
          "State"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "origins"
                ],
                "properties": {
                  "origins": {
                    "type": "array",
                    "maxItems": 20,
                    "items": {
                      "type": "string",
                      "example": "https://username.github.io"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Allowlist replaced",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site": {
                      "type": "string"
                    },
                    "allowed_origins": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/sites/{sitename}/domain": {
      "post": {
        "operationId": "postSitesBySitenameDomain",
        "summary": "Bind a custom domain to this site",
        "description": "Bind ONE custom domain to the site (status starts as `pending`). Returns the DNS CNAME record the owner must create (host → platform CNAME target). Once DNS points at us, the operator issues the certificate and the domain is served at its root. Until first verified, a binding is provisional: another site can take it over, and it expires after 24 hours from binding. A domain verified at least once stays exclusive even if later checks fail. 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": {
                    "took_over_from": {
                      "type": "string",
                      "description": "Previous holder as handle/site; included only on takeover of an unproven binding",
                      "example": "alice/blog"
                    },
                    "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 has been verified by another site at least once",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error",
                    "code"
                  ],
                  "additionalProperties": false,
                  "properties": {
                    "error": {
                      "type": "string",
                      "enum": [
                        "domain is connected to another site"
                      ]
                    },
                    "code": {
                      "type": "string",
                      "enum": [
                        "domain_taken"
                      ]
                    }
                  }
                },
                "example": {
                  "error": "domain is connected to another site",
                  "code": "domain_taken"
                }
              }
            }
          }
        }
      },
      "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"
                      ]
                    },
                    "bound_at": {
                      "type": "string",
                      "format": "date-time",
                      "description": "Binding timestamp stored as domain_bound_at; reset on bind"
                    },
                    "expires_at": {
                      "type": "string",
                      "format": "date-time",
                      "description": "bound_at plus 24 hours; included only while pending and never verified"
                    },
                    "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/admin/api-analytics": {
      "get": {
        "operationId": "getAdminApiAnalytics",
        "summary": "Per-endpoint API call analytics with caller geo (admin)",
        "description": "Admin-only. Aggregated API traffic for the retention window: call counts for today and the trailing week, today's error count, distinct caller IPs, AI build counts, a per-route breakdown and a per-IP breakdown with geo. Served from small day-granular aggregate tables, so it is cheap to poll. A signed-in non-admin gets 404, not 403 — same rule as /v1/admin/users, so the endpoint does not confirm it exists to anyone who may not use it.",
        "tags": [
          "Admin"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "Aggregated API analytics for the retention window.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "calls_today": {
                      "type": "integer"
                    },
                    "calls_week": {
                      "type": "integer"
                    },
                    "errors_today": {
                      "type": "integer"
                    },
                    "ips_today": {
                      "type": "integer"
                    },
                    "ai_builds_today": {
                      "type": "integer"
                    },
                    "ai_builds_week": {
                      "type": "integer"
                    },
                    "retention_days": {
                      "type": "integer"
                    },
                    "routes": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    },
                    "ips": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Not signed in"
          },
          "404": {
            "description": "Signed in but not an admin"
          }
        }
      }
    },
    "/v1/events": {
      "get": {
        "operationId": "listEventDomains",
        "summary": "List the event hostnames you have claimed",
        "description": "Returns every event hostname claimed by the calling account, with the server address it points at and when the claim expires.",
        "tags": [
          "Events"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "Claimed event hostnames",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "domains": {
                      "type": "array",
                      "description": "The event domains this instance can create names under. Ask for these rather than assuming; an instance may offer none.",
                      "items": {
                        "type": "string"
                      },
                      "example": [
                        "simple-hack.app"
                      ]
                    },
                    "events": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "host": {
                            "type": "string",
                            "example": "stanford-cs-2026.simple-host.app"
                          },
                          "content_host": {
                            "type": "string",
                            "example": "sites.stanford-cs-2026.simple-host.app"
                          },
                          "ip": {
                            "type": "string",
                            "example": "85.9.193.170"
                          },
                          "expires_at": {
                            "type": "string",
                            "format": "date-time"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "claimEventDomain",
        "summary": "Claim two hostnames for an event, pointing at your own server",
        "description": "Creates <name>.<domain> and sites.<name>.<domain> as A records at the given public IPv4 address, so a hackathon organiser never touches a registrar. The two hostnames are separate on purpose: participant content must not share a browser origin with the admin interface. Re-claiming a name you already own replaces its records and extends the expiry. A name owned by another account answers 409. Claims expire and are swept, because a record left pointing at a released cloud address is a subdomain takeover.",
        "tags": [
          "Events"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "ip"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "example": "stanford-cs-2026",
                    "description": "1 to 40 letters, digits or hyphens. Lowercased. Reserved names are refused."
                  },
                  "ip": {
                    "type": "string",
                    "example": "85.9.193.170",
                    "description": "Public IPv4 of the organiser's server. Private and loopback addresses are refused."
                  },
                  "domain": {
                    "type": "string",
                    "example": "simple-hack.app",
                    "description": "One of the instance's configured event domains. Defaults to the first."
                  },
                  "move": {
                    "type": "boolean",
                    "description": "Required to re-point an existing claim at a different address. Without it a re-claim that changes the address is refused, because it would silently move a running event."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Hostnames created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "host": {
                      "type": "string"
                    },
                    "content_host": {
                      "type": "string"
                    },
                    "ip": {
                      "type": "string"
                    },
                    "expires_at": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid name",
            "address or domain": null
          },
          "409": {
            "description": "That event name belongs to another account"
          },
          "502": {
            "description": "The DNS provider refused the record"
          }
        }
      }
    },
    "/v1/events/{name}": {
      "delete": {
        "operationId": "releaseEventDomain",
        "summary": "Release an event's hostnames",
        "description": "Removes both DNS records and the claim. Safe to retry. Records are deleted before the row, so a partial failure leaves something that still knows what to clean up.",
        "tags": [
          "Events"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "name": "name",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "domain",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Released"
          },
          "404": {
            "description": "Not found",
            "or not yours": null
          },
          "502": {
            "description": "The DNS provider refused the deletion"
          }
        }
      }
    },
    "/v1/admin/usage": {
      "get": {
        "operationId": "getInstanceUsage",
        "summary": "What this instance is using, and what is left (admin only)",
        "description": "Measured from the filesystem, not projected. There is deliberately no estimate of how many people or sites will fit: the sites on the instance this was built for have a median size of 25 KB against a 100 MB per-site cap, so any figure derived from the cap is wrong by three orders of magnitude. status is ok, filling (75% or more) or full (90% or more). The underlying walk is cached for two minutes. Non-admin callers receive 404.",
        "tags": [
          "Admin"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "Current disk usage",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "disk_bytes",
                    "disk_free_bytes",
                    "disk_used_bytes",
                    "disk_used_pct",
                    "site_bytes",
                    "sites",
                    "status",
                    "message"
                  ],
                  "properties": {
                    "disk_bytes": {
                      "type": "integer",
                      "description": "Size of the filesystem holding site data"
                    },
                    "disk_free_bytes": {
                      "type": "integer"
                    },
                    "disk_used_bytes": {
                      "type": "integer",
                      "description": "Everything on that filesystem",
                      "including Postgres": null,
                      "logs and the OS": null
                    },
                    "disk_used_pct": {
                      "type": "number"
                    },
                    "site_bytes": {
                      "type": "integer",
                      "description": "Site files specifically",
                      "every retained version included": null
                    },
                    "sites": {
                      "type": "integer"
                    },
                    "accounts": {
                      "type": "integer",
                      "description": "Participant accounts",
                      "or -1 if the count could not be read": null
                    },
                    "site_limit_mb": {
                      "type": "integer",
                      "description": "Per-site cap in force"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "ok",
                        "filling",
                        "full"
                      ]
                    },
                    "message": {
                      "type": "string",
                      "description": "One sentence for an admin screen"
                    },
                    "measured_at": {
                      "type": "string",
                      "format": "date-time",
                      "description": "When the walk behind these figures ran. Readings up to two minutes old are served while a refresh runs behind the request"
                    },
                    "largest": {
                      "type": "array",
                      "description": "The biggest sites, largest first, so a full disk has a visible cause",
                      "items": {
                        "type": "object",
                        "properties": {
                          "user_id": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          },
                          "bytes": {
                            "type": "integer"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Caller is not an admin"
          },
          "500": {
            "description": "This server's disk could not be read"
          }
        }
      }
    },
    "/v1/admin/users": {
      "post": {
        "operationId": "createEventAccounts",
        "summary": "Create participant accounts (admin only)",
        "description": "Creates accounts from emails or a count and optional prefix (default guest), numbered team-01 through team-30 for count 30 and prefix team. There is no fixed ceiling: ask for ten thousand and you get ten thousand, if the server has room for them. How many that is comes from its disk and its per-site limit, and GET /v1/admin/capacity reports it. A request beyond that room is refused with 409 and the real number rather than part-filled. Existing usernames are skipped with a reason and their keys are never disclosed. Non-admin callers receive 404.",
        "tags": [
          "Admin"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "type": "object",
                    "required": [
                      "emails"
                    ],
                    "additionalProperties": false,
                    "properties": {
                      "emails": {
                        "type": "array",
                        "minItems": 1,
                        "items": {
                          "type": "string",
                          "format": "email"
                        }
                      }
                    }
                  },
                  {
                    "type": "object",
                    "required": [
                      "count"
                    ],
                    "additionalProperties": false,
                    "properties": {
                      "count": {
                        "type": "integer",
                        "minimum": 1
                      },
                      "prefix": {
                        "type": "string",
                        "default": "guest",
                        "pattern": "^[a-z0-9-]{1,39}$"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Accounts created and existing usernames skipped",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "created",
                    "skipped"
                  ],
                  "properties": {
                    "created": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "username": {
                            "type": "string"
                          },
                          "handle": {
                            "type": "string"
                          },
                          "api_key": {
                            "type": "string"
                          },
                          "display_name": {
                            "type": "string",
                            "nullable": true
                          }
                        }
                      }
                    },
                    "skipped": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "username": {
                            "type": "string"
                          },
                          "reason": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "get": {
        "operationId": "getAdminUsers",
        "summary": "List all users + their sites (admin)",
        "description": "Admin-only. Returns every registered user with their sites nested (API keys are never included). Powers the /admin dashboard. A signed-in non-admin gets 404, not 403 — the endpoint does not confirm it exists to anyone who may not use it, and /admin renders that as an ordinary not-found page.",
        "tags": [
          "Admin"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "All users with nested sites and totals.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "user_count": {
                      "type": "integer"
                    },
                    "site_count": {
                      "type": "integer"
                    },
                    "users": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "username": {
                            "type": "string"
                          },
                          "handle": {
                            "type": "string"
                          },
                          "is_admin": {
                            "type": "boolean"
                          },
                          "created_at": {
                            "type": "string",
                            "format": "date-time"
                          },
                          "site_count": {
                            "type": "integer"
                          },
                          "sites": {
                            "type": "array",
                            "items": {
                              "type": "object"
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "not an admin (deliberately indistinguishable from a route that does not exist)"
          }
        }
      }
    },
    "/v1/transcribe": {
      "post": {
        "operationId": "postTranscribe",
        "summary": "Transcribe a voice recording to text",
        "description": "Powers the mic button in the builder chat. POST the raw audio bytes as the body with the recording's Content-Type — WebM/Opus (Chrome) and MP4/AAC (iOS Safari) are both accepted, since browsers disagree and the server sniffs the container itself. Speech-to-text runs on this box (Moonshine, CPU-only), so audio is never sent to a third party. Returns the text for the user to edit and send; it does not start a build. Sign-in-gated and rate limited. Absent when the server has no TRANSCRIBE_URL configured.",
        "tags": [
          "AI"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/octet-stream": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Transcript. Empty text means no speech was detected.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "text": {
                      "type": "string"
                    },
                    "seconds": {
                      "type": "number",
                      "description": "duration of the decoded audio"
                    },
                    "took_ms": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "no audio",
            "or the container could not be decoded": 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 / text on the latest user turn",
                    "items": {
                      "type": "object",
                      "properties": {
                        "kind": {
                          "type": "string",
                          "enum": [
                            "image",
                            "document",
                            "text"
                          ]
                        },
                        "mediaType": {
                          "type": "string"
                        },
                        "name": {
                          "type": "string"
                        },
                        "data": {
                          "type": "string",
                          "description": "base64 (image/document)"
                        },
                        "text": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Returns a jobId; poll /v1/generate/status until it reports done. Every backend answers this way. A full build takes a minute or more, which is far too long to hold one HTTP response open — browsers drop an idle connection long before it finishes.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "jobId": {
                      "type": "string",
                      "description": "poll /v1/generate/status with this"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "503": {
            "description": "too many builds already running; retry shortly"
          }
        }
      }
    },
    "/v1/generate/status": {
      "get": {
        "operationId": "getGenerateStatus",
        "summary": "Poll an async AI-create job",
        "description": "Poll the status of a job started by POST /v1/generate. Returns running until the build finishes, then done with the reply and HTML (or error with a message). Poll every couple of seconds. Sign-in-gated, and a job is visible only to the user who started it — anyone else gets 404, as does an id that has expired. Jobs are kept 10 minutes after finishing.",
        "tags": [
          "AI"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "jobId from POST /v1/generate"
          }
        ],
        "responses": {
          "200": {
            "description": "Job status",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "enum": [
                        "running",
                        "done",
                        "error"
                      ]
                    },
                    "reply": {
                      "type": "string"
                    },
                    "html": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Unknown or expired job"
          }
        }
      }
    },
    "/healthz": {
      "get": {
        "operationId": "getHealthz",
        "summary": "Liveness probe",
        "tags": [
          "Health"
        ],
        "responses": {
          "200": {
            "description": "Alive",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "example": "ok"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/readyz": {
      "get": {
        "operationId": "getReadyz",
        "summary": "Readiness probe (DB connected)",
        "tags": [
          "Health"
        ],
        "responses": {
          "200": {
            "description": "Ready",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "example": "ok"
                    }
                  }
                }
              }
            }
          },
          "503": {
            "description": "Not ready"
          }
        }
      }
    },
    "/v1/sites/{sitename}/visitor/auth": {
      "post": {
        "operationId": "requestSiteVisitorEmail",
        "summary": "Request a site-scoped email sign-in code",
        "description": "Origin-gated; sign-in is available only on the site's bound custom domain, unavailable on the apex and shared content host. Uses the shared account email-code limiter and site IP limiter. Normalizes and validates email, then sends a 6-digit Resend code valid for 15 minutes. Does not create the account yet.",
        "tags": [
          "Auth"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email",
                    "example": "person@example.com"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Code sent; no dashboard magic link.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "message",
                    "email",
                    "expires_in_seconds"
                  ],
                  "properties": {
                    "message": {
                      "type": "string",
                      "enum": [
                        "Check your email for a sign-in code."
                      ]
                    },
                    "email": {
                      "type": "string",
                      "format": "email"
                    },
                    "expires_in_seconds": {
                      "type": "integer",
                      "enum": [
                        900
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid request, missing fields, apex host, or shared content host (custom_domain_required)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "sign-in needs a custom domain",
                  "code": "custom_domain_required"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or expired code; attempt cap reports too many attempts, request a new code. Verification only.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "invalid or expired code",
                  "code": "invalid_code"
                }
              }
            }
          },
          "403": {
            "description": "Origin forbidden or missing CSRF (verification)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "forbidden"
                }
              }
            }
          },
          "404": {
            "description": "Site not found",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "site not found"
                }
              }
            }
          },
          "429": {
            "description": "IP or shared per-email rate limit exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "rate limit exceeded, slow down"
                }
              }
            }
          },
          "500": {
            "description": "Internal error or email delivery failed",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "internal server error"
                }
              }
            }
          }
        }
      },
      "options": {
        "operationId": "optionsRequestSiteVisitorEmail",
        "summary": "Preflight site email sign-in",
        "tags": [
          "Auth"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "204": {
            "description": "Origin-authorized preflight",
            "headers": {
              "Access-Control-Allow-Methods": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "POST, OPTIONS"
                  ]
                }
              },
              "Access-Control-Allow-Headers": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "Content-Type, X-SH-CSRF"
                  ]
                }
              },
              "Access-Control-Max-Age": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "600"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Missing site name"
          },
          "403": {
            "description": "Origin forbidden"
          }
        }
      }
    },
    "/v1/sites/{sitename}/visitor/auth/verify": {
      "post": {
        "operationId": "verifySiteVisitorEmail",
        "summary": "Verify a site-scoped email sign-in code",
        "description": "Origin-gated; sign-in is available only on the site's bound custom domain, unavailable on the apex and shared content host. Uses the shared account email-code limiter and dedicated visitor auth IP limiter. Codes are bound to this site and the visitor purpose; dashboard codes are not accepted. Checks the latest unused, unexpired token, caps attempts, and creates the account lazily. Never returns an API key. Session has 30-day absolute and 14-day idle lifetimes. Send X-SH-CSRF: 1 is required explicitly; application/json does not satisfy this requirement.",
        "tags": [
          "Auth"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/csrf"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email",
                  "code"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email",
                    "example": "person@example.com"
                  },
                  "code": {
                    "type": "string",
                    "example": "123456",
                    "description": "6-digit email code"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Signed in; sets the existing host-only visitor session cookie. Email/provider are included on the custom domain; shared-host requests return 400 custom_domain_required.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "signed_in",
                    "expires_at"
                  ],
                  "properties": {
                    "signed_in": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    },
                    "email": {
                      "type": "string",
                      "format": "email"
                    },
                    "provider": {
                      "type": "string",
                      "enum": [
                        "email"
                      ]
                    },
                    "expires_at": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid request, missing fields, apex host, or shared content host (custom_domain_required)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "sign-in needs a custom domain",
                  "code": "custom_domain_required"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or expired code; attempt cap reports too many attempts, request a new code. Verification only.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "invalid or expired code",
                  "code": "invalid_code"
                }
              }
            }
          },
          "403": {
            "description": "Origin forbidden or missing CSRF (verification)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "missing CSRF header",
                  "code": "csrf_required"
                }
              }
            }
          },
          "404": {
            "description": "Site not found",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "site not found"
                }
              }
            }
          },
          "429": {
            "description": "IP or shared per-email rate limit exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "rate limit exceeded, slow down"
                }
              }
            }
          },
          "500": {
            "description": "Internal error or email delivery failed",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "internal server error"
                }
              }
            }
          }
        }
      },
      "options": {
        "operationId": "optionsVerifySiteVisitorEmail",
        "summary": "Preflight site email sign-in",
        "tags": [
          "Auth"
        ],
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "204": {
            "description": "Origin-authorized preflight",
            "headers": {
              "Access-Control-Allow-Methods": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "POST, OPTIONS"
                  ]
                }
              },
              "Access-Control-Allow-Headers": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "Content-Type, X-SH-CSRF"
                  ]
                }
              },
              "Access-Control-Max-Age": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "600"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Missing site name"
          },
          "403": {
            "description": "Origin forbidden"
          }
        }
      }
    },
    "/v1/u/{handle}/sites/{sitename}/visitor/auth": {
      "post": {
        "operationId": "requestUserSiteVisitorEmail",
        "summary": "Request a site-scoped email sign-in code",
        "description": "Origin-gated; sign-in is available only on the site's bound custom domain, unavailable on the apex and shared content host. Uses the shared account email-code limiter and site IP limiter. Normalizes and validates email, then sends a 6-digit Resend code valid for 15 minutes. Does not create the account yet.",
        "tags": [
          "Auth"
        ],
        "security": [],
        "parameters": [
          {
            "name": "handle",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email",
                    "example": "person@example.com"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Code sent; no dashboard magic link.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "message",
                    "email",
                    "expires_in_seconds"
                  ],
                  "properties": {
                    "message": {
                      "type": "string",
                      "enum": [
                        "Check your email for a sign-in code."
                      ]
                    },
                    "email": {
                      "type": "string",
                      "format": "email"
                    },
                    "expires_in_seconds": {
                      "type": "integer",
                      "enum": [
                        900
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid request, missing fields, apex host, or shared content host (custom_domain_required)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "sign-in needs a custom domain",
                  "code": "custom_domain_required"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or expired code; attempt cap reports too many attempts, request a new code. Verification only.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "invalid or expired code",
                  "code": "invalid_code"
                }
              }
            }
          },
          "403": {
            "description": "Origin forbidden or missing CSRF (verification)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "forbidden"
                }
              }
            }
          },
          "404": {
            "description": "Site not found",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "site not found"
                }
              }
            }
          },
          "429": {
            "description": "IP or shared per-email rate limit exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "rate limit exceeded, slow down"
                }
              }
            }
          },
          "500": {
            "description": "Internal error or email delivery failed",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "internal server error"
                }
              }
            }
          }
        }
      },
      "options": {
        "operationId": "optionsRequestUserSiteVisitorEmail",
        "summary": "Preflight site email sign-in",
        "tags": [
          "Auth"
        ],
        "security": [],
        "parameters": [
          {
            "name": "handle",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "204": {
            "description": "Origin-authorized preflight",
            "headers": {
              "Access-Control-Allow-Methods": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "POST, OPTIONS"
                  ]
                }
              },
              "Access-Control-Allow-Headers": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "Content-Type, X-SH-CSRF"
                  ]
                }
              },
              "Access-Control-Max-Age": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "600"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Missing site name"
          },
          "403": {
            "description": "Origin forbidden"
          }
        }
      }
    },
    "/v1/u/{handle}/sites/{sitename}/visitor/auth/verify": {
      "post": {
        "operationId": "verifyUserSiteVisitorEmail",
        "summary": "Verify a site-scoped email sign-in code",
        "description": "Origin-gated; sign-in is available only on the site's bound custom domain, unavailable on the apex and shared content host. Uses the shared account email-code limiter and dedicated visitor auth IP limiter. Codes are bound to this site and the visitor purpose; dashboard codes are not accepted. Checks the latest unused, unexpired token, caps attempts, and creates the account lazily. Never returns an API key. Session has 30-day absolute and 14-day idle lifetimes. Send X-SH-CSRF: 1 is required explicitly; application/json does not satisfy this requirement.",
        "tags": [
          "Auth"
        ],
        "security": [],
        "parameters": [
          {
            "name": "handle",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/csrf"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email",
                  "code"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email",
                    "example": "person@example.com"
                  },
                  "code": {
                    "type": "string",
                    "example": "123456",
                    "description": "6-digit email code"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Signed in; sets the existing host-only visitor session cookie. Email/provider are included on the custom domain; shared-host requests return 400 custom_domain_required.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "signed_in",
                    "expires_at"
                  ],
                  "properties": {
                    "signed_in": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    },
                    "email": {
                      "type": "string",
                      "format": "email"
                    },
                    "provider": {
                      "type": "string",
                      "enum": [
                        "email"
                      ]
                    },
                    "expires_at": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid request, missing fields, apex host, or shared content host (custom_domain_required)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "sign-in needs a custom domain",
                  "code": "custom_domain_required"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or expired code; attempt cap reports too many attempts, request a new code. Verification only.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "invalid or expired code",
                  "code": "invalid_code"
                }
              }
            }
          },
          "403": {
            "description": "Origin forbidden or missing CSRF (verification)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "missing CSRF header",
                  "code": "csrf_required"
                }
              }
            }
          },
          "404": {
            "description": "Site not found",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "site not found"
                }
              }
            }
          },
          "429": {
            "description": "IP or shared per-email rate limit exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "rate limit exceeded, slow down"
                }
              }
            }
          },
          "500": {
            "description": "Internal error or email delivery failed",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "code": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "error": "internal server error"
                }
              }
            }
          }
        }
      },
      "options": {
        "operationId": "optionsVerifyUserSiteVisitorEmail",
        "summary": "Preflight site email sign-in",
        "tags": [
          "Auth"
        ],
        "security": [],
        "parameters": [
          {
            "name": "handle",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "204": {
            "description": "Origin-authorized preflight",
            "headers": {
              "Access-Control-Allow-Methods": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "POST, OPTIONS"
                  ]
                }
              },
              "Access-Control-Allow-Headers": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "Content-Type, X-SH-CSRF"
                  ]
                }
              },
              "Access-Control-Max-Age": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "600"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Missing site name"
          },
          "403": {
            "description": "Origin forbidden"
          }
        }
      }
    },
    "/v1/admin/users/{id}": {
      "delete": {
        "operationId": "deleteEventAccount",
        "summary": "Delete an account and its files (admin only)",
        "description": "Deletes the account, cascading database records, its by-id directory and handle symlink. Admin accounts cannot be deleted. Non-admin callers receive 404.",
        "tags": [
          "Admin"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Account deleted"
          },
          "400": {
            "description": "Cannot delete an admin account"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    }
  },
  "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": {
      "TrafficCounts": {
        "type": "object",
        "properties": {
          "views": {
            "type": "integer",
            "example": 61
          },
          "visitors": {
            "type": "integer",
            "description": "Unique visitors — distinct hashed IPs over this bucket's window",
            "example": 24
          }
        }
      },
      "TrafficSplit": {
        "type": "object",
        "description": "One bucket of traffic, split by who was asking. `person` is the audience figure; `infra` is the site's own monitoring and is normally far larger than either of the other two. `unknown` is non-zero only for days before `classified_from`, which were recorded before traffic was classified and cannot be broken down after the fact — do not fold it into any of the other three.",
        "properties": {
          "person": {
            "$ref": "#/components/schemas/TrafficCounts"
          },
          "bot": {
            "$ref": "#/components/schemas/TrafficCounts"
          },
          "infra": {
            "$ref": "#/components/schemas/TrafficCounts"
          },
          "unknown": {
            "$ref": "#/components/schemas/TrafficCounts"
          }
        }
      },
      "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."
          },
          "files_base64": {
            "type": "object",
            "additionalProperties": {
              "type": "string",
              "format": "byte"
            },
            "description": "relative path -> base64-encoded binary contents. A path cannot also appear in `files`."
          }
        },
        "example": {
          "files": {
            "index.html": "<!DOCTYPE html><h1>Hello</h1>",
            "css/style.css": "body{font-family:sans-serif}"
          },
          "files_base64": {
            "assets/logo.png": "iVBORw0KGgoAAAANSUhEUg..."
          }
        }
      },
      "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"
              }
            }
          }
        ]
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Invalid request",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing or invalid API key — send the X-API-Key header, not Authorization Bearer",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Forbidden": {
        "description": "Admin access required",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "Not found",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Conflict": {
        "description": "Site name already exists",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "TooLarge": {
        "description": "Payload too large",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "Rate limited",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "OriginMismatch": {
        "description": "Origin/Referer does not match the site",
        "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
            }
          }
        }
      }
    }
  }
}