{
  "openapi": "3.1.0",
  "info": {
    "title": "harvis.dev API",
    "version": "1",
    "description": "Everything an account can control, over HTTP. This document is generated from lib/api/contract.ts, which is also what the dashboard's server actions call — so what the harvis CLI can do and what the web app can do are the same list by construction. Do not edit this file by hand; run `bun run api:generate`."
  },
  "servers": [
    {
      "url": "https://harvis.dev"
    }
  ],
  "tags": [
    {
      "name": "meta",
      "description": "Checking that a credential works."
    },
    {
      "name": "sites",
      "description": "Creating, deploying and managing hosted sites."
    },
    {
      "name": "files",
      "description": "The files of one site."
    },
    {
      "name": "submissions",
      "description": "What visitors typed into a <form harvis-form>."
    },
    {
      "name": "keys",
      "description": "Account API keys. Session-only: a key cannot mint another key."
    }
  ],
  "paths": {
    "/api/v1": {
      "get": {
        "operationId": "meta.index",
        "summary": "Check that a credential works.",
        "description": "The first call to make after creating a key: it does nothing, costs nothing, and says which kind of credential answered.",
        "tags": [
          "meta"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "session": []
          }
        ],
        "responses": {
          "200": {
            "description": "Check that a credential works.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiIndex"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`suspended`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/keys": {
      "get": {
        "operationId": "keys.list",
        "summary": "The account's API keys. Revoked keys are not listed.",
        "tags": [
          "keys"
        ],
        "security": [
          {
            "session": []
          }
        ],
        "responses": {
          "200": {
            "description": "The account's API keys. Revoked keys are not listed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiKeyList"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`crossOrigin`, `sessionRequired`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "keys.create",
        "summary": "Create an API key.",
        "description": "The token comes back in this response and cannot be retrieved again — the row keeps only a digest of it. Session-only: a key cannot mint another key, or revoking one would mean nothing.",
        "tags": [
          "keys"
        ],
        "security": [
          {
            "session": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateApiKeyInput"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Create an API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreateApiKeyResult"
                }
              }
            }
          },
          "400": {
            "description": "`invalidRequest`, `nameRequired`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`crossOrigin`, `sessionRequired`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/keys/{keyId}": {
      "delete": {
        "operationId": "keys.revoke",
        "summary": "Revoke an API key. Anything using it stops working immediately.",
        "tags": [
          "keys"
        ],
        "security": [
          {
            "session": []
          }
        ],
        "parameters": [
          {
            "name": "keyId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Revoke an API key. Anything using it stops working immediately.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OkResult"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`crossOrigin`, `sessionRequired`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "`keyNotFound`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/me": {
      "get": {
        "operationId": "me.get",
        "summary": "The account a credential belongs to.",
        "tags": [
          "meta"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "session": []
          }
        ],
        "responses": {
          "200": {
            "description": "The account a credential belongs to.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MeResult"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`suspended`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/sites": {
      "get": {
        "operationId": "sites.list",
        "summary": "List the account's sites, newest first.",
        "tags": [
          "sites"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "session": []
          }
        ],
        "responses": {
          "200": {
            "description": "List the account's sites, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteList"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`suspended`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "sites.create",
        "summary": "Create a site from a folder of files.",
        "description": "The site belongs to the account that authorized the call — there is no claim link and no 24-hour expiry, unlike an anonymous deploy to /api/upload. A single shared top-level folder is stripped, junk files (.DS_Store, __MACOSX…) are dropped, and the homepage is chosen as the root index.html if there is one. 50 MB and 500 files.",
        "tags": [
          "sites"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "session": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/CreateSiteInput"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Create a site from a folder of files.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreateSiteResult"
                }
              }
            }
          },
          "400": {
            "description": "`invalidRequest`, `noFiles`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`suspended`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "413": {
            "description": "`tooLarge`, `tooManyFiles`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/sites/{id}": {
      "delete": {
        "operationId": "sites.delete",
        "summary": "Delete a site, its files and its form submissions.",
        "description": "The address is released and the site stops resolving. This cannot be undone.",
        "tags": [
          "sites"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Delete a site, its files and its form submissions.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OkResult"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`suspended`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "`siteNotFound`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      },
      "get": {
        "operationId": "sites.get",
        "summary": "One site.",
        "tags": [
          "sites"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The site's id, as returned by sites.list.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "One site.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteResult"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`suspended`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "`siteNotFound`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      },
      "patch": {
        "operationId": "sites.update",
        "summary": "Rename a site, change its web address, or both.",
        "description": "Absent fields are left alone. A subdomain is unique across the whole service, not just this account, so a taken one is refused rather than queued.",
        "tags": [
          "sites"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateSiteInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Rename a site, change its web address, or both.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteResult"
                }
              }
            }
          },
          "400": {
            "description": "`invalidRequest`, `nameRequired`, `subdomainInvalid`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`suspended`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "`siteNotFound`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "409": {
            "description": "`subdomainTaken`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/sites/{id}/deploy": {
      "post": {
        "operationId": "sites.deploy",
        "summary": "Replace every file of a site with the uploaded set.",
        "description": "The site's whole file list is replaced: paths absent from the upload are deleted from storage. Form submissions belong to the site, not to the deploy, and survive. The web address does not change. 50 MB and 500 files.",
        "tags": [
          "sites"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/DeploySiteInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Replace every file of a site with the uploaded set.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeployResult"
                }
              }
            }
          },
          "400": {
            "description": "`invalidRequest`, `noFiles`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`suspended`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "`siteNotFound`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "413": {
            "description": "`tooLarge`, `tooManyFiles`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/sites/{id}/deploy-token": {
      "post": {
        "operationId": "sites.rotateToken",
        "summary": "Issue a new deploy token for a site. The old one stops working immediately.",
        "description": "A deploy token is scoped to one site and can only redeploy it — that is what the GitHub Action and `harvis link` use. An account key does strictly more; this exists for handing a single site to something you would rather not trust with the account.",
        "tags": [
          "sites"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Issue a new deploy token for a site. The old one stops working immediately.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeployTokenResult"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`suspended`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "`siteNotFound`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/sites/{id}/deploy/zip": {
      "post": {
        "operationId": "sites.deployZip",
        "summary": "Replace every file of a site from a zip archive sent as the raw request body.",
        "description": "The same operation as sites.deploy, for callers that would rather not assemble a multipart body: `curl --data-binary @site.zip -H 'Content-Type: application/zip'`. A single shared top-level folder inside the archive is stripped.",
        "tags": [
          "sites"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "name",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 120
            }
          }
        ],
        "requestBody": {
          "required": true,
          "description": "The archive itself, as the raw body.",
          "content": {
            "application/zip": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Replace every file of a site from a zip archive sent as the raw request body.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeployResult"
                }
              }
            }
          },
          "400": {
            "description": "`invalidRequest`, `noFiles`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`suspended`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "`siteNotFound`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "413": {
            "description": "`tooLarge`, `tooManyFiles`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/sites/{id}/files": {
      "get": {
        "operationId": "files.list",
        "summary": "Every file a site is serving.",
        "description": "Paths are relative to the site root. To read a file's contents, fetch it from the site's own URL — it is a public web page.",
        "tags": [
          "files"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Every file a site is serving.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteFileList"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`suspended`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "`siteNotFound`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "files.add",
        "summary": "Add or overwrite individual files, leaving the rest of the site alone.",
        "description": "Unlike sites.deploy, paths are taken exactly as given: nothing is stripped and nothing is deleted. Use this to change one page; use sites.deploy to publish a rebuilt folder.",
        "tags": [
          "files"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/AddFilesInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Add or overwrite individual files, leaving the rest of the site alone.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AddFilesResult"
                }
              }
            }
          },
          "400": {
            "description": "`invalidRequest`, `noFiles`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`suspended`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "`siteNotFound`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "413": {
            "description": "`tooLarge`, `tooManyFiles`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/sites/{id}/submissions": {
      "delete": {
        "operationId": "submissions.deleteAll",
        "summary": "Delete every submission for a site, or every one of a single form.",
        "tags": [
          "submissions"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "form",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Delete every submission for a site, or every one of a single form.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeletedResult"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`suspended`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "`siteNotFound`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      },
      "get": {
        "operationId": "submissions.list",
        "summary": "One page of a site's form submissions, newest first.",
        "tags": [
          "submissions"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "form",
            "in": "query",
            "required": false,
            "description": "Restrict to one harvis-form name.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Rows to skip. The page size is 50.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 9007199254740991
            }
          }
        ],
        "responses": {
          "200": {
            "description": "One page of a site's form submissions, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SubmissionList"
                }
              }
            }
          },
          "400": {
            "description": "`invalidRequest`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`suspended`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "`siteNotFound`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/sites/{id}/submissions/{submissionId}": {
      "delete": {
        "operationId": "submissions.delete",
        "summary": "Delete one submission.",
        "tags": [
          "submissions"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "submissionId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Delete one submission.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OkResult"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`suspended`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "`siteNotFound`, `submissionNotFound`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      },
      "get": {
        "operationId": "submissions.get",
        "summary": "One submission.",
        "tags": [
          "submissions"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "submissionId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "One submission.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SubmissionResult"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`suspended`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "`siteNotFound`, `submissionNotFound`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/sites/{id}/submissions/csv": {
      "get": {
        "operationId": "submissions.csv",
        "summary": "Export a site's form submissions as CSV.",
        "tags": [
          "submissions"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "form",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "One column per field anyone ever submitted, in first-seen order, sent as an attachment named <subdomain>-<form>.csv. Values beginning =, +, - or @ are quote-prefixed so a spreadsheet does not execute visitor-supplied text as a formula.",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`suspended`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "`siteNotFound`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/sites/{id}/submissions/read": {
      "post": {
        "operationId": "submissions.markRead",
        "summary": "Mark every unread submission as read.",
        "tags": [
          "submissions"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Mark every unread submission as read.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OkResult"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`suspended`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "`siteNotFound`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/sites/{id}/submissions/summary": {
      "get": {
        "operationId": "submissions.summary",
        "summary": "How many submissions a site holds, how many are unread, and which forms exist.",
        "tags": [
          "submissions"
        ],
        "security": [
          {
            "apiKey": []
          },
          {
            "session": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "How many submissions a site holds, how many are unread, and which forms exist.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SubmissionSummary"
                }
              }
            }
          },
          "401": {
            "description": "`unauthorized`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`suspended`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "`siteNotFound`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Account": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "email": {
            "type": "string"
          },
          "image": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "id",
          "name",
          "email",
          "image"
        ],
        "additionalProperties": false
      },
      "AddFilesInput": {
        "type": "object",
        "properties": {
          "files": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "binary",
              "contentEncoding": "binary"
            }
          },
          "paths": {
            "description": "One per file, same order — the path relative to the site root.",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "files",
          "paths"
        ],
        "additionalProperties": false
      },
      "AddFilesResult": {
        "type": "object",
        "properties": {
          "site": {
            "$ref": "#/components/schemas/Site"
          },
          "added": {
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          }
        },
        "required": [
          "site",
          "added"
        ],
        "additionalProperties": false
      },
      "ApiError": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "description": "A stable machine code — see docs/api.md for the full list.",
                "type": "string"
              },
              "message": {
                "description": "English prose, for a human reading a log.",
                "type": "string"
              }
            },
            "required": [
              "code",
              "message"
            ],
            "additionalProperties": false
          }
        },
        "required": [
          "error"
        ],
        "additionalProperties": false
      },
      "ApiIndex": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          },
          "via": {
            "description": "Which credential the request was accepted on.",
            "type": "string",
            "enum": [
              "key",
              "session"
            ]
          },
          "endpoints": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "documentation": {
            "type": "string"
          }
        },
        "required": [
          "ok",
          "via",
          "endpoints",
          "documentation"
        ],
        "additionalProperties": false
      },
      "ApiKey": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "prefix": {
            "description": "The first characters of the token, so a key can be recognised in a list.",
            "type": "string"
          },
          "lastUsedAt": {
            "description": "Written at most once an hour, so it lags by up to that.",
            "anyOf": [
              {
                "format": "date-time",
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "createdAt": {
            "format": "date-time",
            "type": "string"
          }
        },
        "required": [
          "id",
          "name",
          "prefix",
          "lastUsedAt",
          "createdAt"
        ],
        "additionalProperties": false
      },
      "ApiKeyList": {
        "type": "object",
        "properties": {
          "keys": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ApiKey"
            }
          }
        },
        "required": [
          "keys"
        ],
        "additionalProperties": false
      },
      "CreateApiKeyInput": {
        "type": "object",
        "properties": {
          "name": {
            "description": "What the key is for, so you can recognise it later.",
            "type": "string",
            "maxLength": 120
          }
        },
        "required": [
          "name"
        ],
        "additionalProperties": false
      },
      "CreateApiKeyResult": {
        "type": "object",
        "properties": {
          "key": {
            "$ref": "#/components/schemas/ApiKey"
          },
          "token": {
            "description": "Shown exactly once. Send it as `Authorization: Bearer <token>`.",
            "type": "string"
          }
        },
        "required": [
          "key",
          "token"
        ],
        "additionalProperties": false
      },
      "CreateSiteInput": {
        "type": "object",
        "properties": {
          "files": {
            "description": "One part per file of the site.",
            "type": "array",
            "items": {
              "type": "string",
              "format": "binary",
              "contentEncoding": "binary"
            }
          },
          "paths": {
            "description": "One part per file, in the same order — the file's path relative to the site root. Required because multipart strips directory separators from a file name.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "name": {
            "description": "Defaults to the folder name, then to the homepage's <title>.",
            "type": "string",
            "maxLength": 120
          }
        },
        "required": [
          "files",
          "paths"
        ],
        "additionalProperties": false
      },
      "CreateSiteResult": {
        "type": "object",
        "properties": {
          "site": {
            "$ref": "#/components/schemas/Site"
          },
          "deployToken": {
            "description": "Scoped to this one site. Shown here so a CI job can be given it without the account key.",
            "type": "string"
          }
        },
        "required": [
          "site",
          "deployToken"
        ],
        "additionalProperties": false
      },
      "DeletedResult": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          },
          "removed": {
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          }
        },
        "required": [
          "ok",
          "removed"
        ],
        "additionalProperties": false
      },
      "DeployResult": {
        "type": "object",
        "properties": {
          "site": {
            "$ref": "#/components/schemas/Site"
          },
          "replaced": {
            "description": "How many files the site now serves.",
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          }
        },
        "required": [
          "site",
          "replaced"
        ],
        "additionalProperties": false
      },
      "DeploySiteInput": {
        "type": "object",
        "properties": {
          "files": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "binary",
              "contentEncoding": "binary"
            }
          },
          "paths": {
            "description": "One per file, same order — the path relative to the site root.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "name": {
            "description": "Renames the site as well, if given.",
            "type": "string",
            "maxLength": 120
          }
        },
        "required": [
          "files",
          "paths"
        ],
        "additionalProperties": false
      },
      "DeployTokenResult": {
        "type": "object",
        "properties": {
          "deployToken": {
            "type": "string"
          }
        },
        "required": [
          "deployToken"
        ],
        "additionalProperties": false
      },
      "MeResult": {
        "type": "object",
        "properties": {
          "user": {
            "$ref": "#/components/schemas/Account"
          }
        },
        "required": [
          "user"
        ],
        "additionalProperties": false
      },
      "OkResult": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          }
        },
        "required": [
          "ok"
        ],
        "additionalProperties": false
      },
      "Site": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "subdomain": {
            "type": "string"
          },
          "url": {
            "description": "Where the site is served, e.g. https://sunny-otter-42.harvis.dev.",
            "type": "string"
          },
          "entrypoint": {
            "description": "The file served at the site's root.",
            "type": "string"
          },
          "fileCount": {
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          },
          "totalSize": {
            "description": "Bytes stored, summed over every file.",
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          },
          "createdAt": {
            "format": "date-time",
            "type": "string"
          },
          "updatedAt": {
            "format": "date-time",
            "type": "string"
          }
        },
        "required": [
          "id",
          "name",
          "subdomain",
          "url",
          "entrypoint",
          "fileCount",
          "totalSize",
          "createdAt",
          "updatedAt"
        ],
        "additionalProperties": false
      },
      "SiteFile": {
        "type": "object",
        "properties": {
          "path": {
            "description": "Relative to the site root, e.g. assets/app.css.",
            "type": "string"
          },
          "contentType": {
            "type": "string"
          },
          "size": {
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          }
        },
        "required": [
          "path",
          "contentType",
          "size"
        ],
        "additionalProperties": false
      },
      "SiteFileList": {
        "type": "object",
        "properties": {
          "files": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SiteFile"
            }
          }
        },
        "required": [
          "files"
        ],
        "additionalProperties": false
      },
      "SiteList": {
        "type": "object",
        "properties": {
          "sites": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Site"
            }
          }
        },
        "required": [
          "sites"
        ],
        "additionalProperties": false
      },
      "SiteResult": {
        "type": "object",
        "properties": {
          "site": {
            "$ref": "#/components/schemas/Site"
          }
        },
        "required": [
          "site"
        ],
        "additionalProperties": false
      },
      "Submission": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "formName": {
            "description": "The value of the harvis-form attribute; a bare attribute is 'default'.",
            "type": "string"
          },
          "data": {
            "description": "Field name to value, exactly as the visitor's browser sent it.",
            "type": "object",
            "propertyNames": {
              "type": "string"
            },
            "additionalProperties": {
              "type": "string"
            }
          },
          "pagePath": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "readAt": {
            "anyOf": [
              {
                "format": "date-time",
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "createdAt": {
            "format": "date-time",
            "type": "string"
          }
        },
        "required": [
          "id",
          "formName",
          "data",
          "pagePath",
          "readAt",
          "createdAt"
        ],
        "additionalProperties": false
      },
      "SubmissionList": {
        "type": "object",
        "properties": {
          "submissions": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Submission"
            }
          },
          "hasMore": {
            "description": "Whether another page follows this one.",
            "type": "boolean"
          }
        },
        "required": [
          "submissions",
          "hasMore"
        ],
        "additionalProperties": false
      },
      "SubmissionResult": {
        "type": "object",
        "properties": {
          "submission": {
            "$ref": "#/components/schemas/Submission"
          }
        },
        "required": [
          "submission"
        ],
        "additionalProperties": false
      },
      "SubmissionSummary": {
        "type": "object",
        "properties": {
          "total": {
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          },
          "unread": {
            "type": "integer",
            "minimum": -9007199254740991,
            "maximum": 9007199254740991
          },
          "forms": {
            "description": "Distinct harvis-form names seen on this site.",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "total",
          "unread",
          "forms"
        ],
        "additionalProperties": false
      },
      "UpdateSiteInput": {
        "type": "object",
        "properties": {
          "name": {
            "description": "A label for the dashboard; not shown to visitors.",
            "type": "string",
            "maxLength": 120
          },
          "subdomain": {
            "description": "3–63 lowercase letters, numbers or hyphens. The site moves to it immediately.",
            "type": "string"
          }
        },
        "additionalProperties": false
      }
    },
    "securitySchemes": {
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "hvs_…",
        "description": "An account key, created at https://harvis.dev/dashboard/keys and shown once."
      },
      "session": {
        "type": "apiKey",
        "in": "cookie",
        "name": "better-auth.session_token",
        "description": "The dashboard's own sign-in cookie. Listed because the browser calls these same endpoints; a CLI or a server should use apiKey instead."
      }
    }
  }
}
