{
  "openapi": "3.0.3",
  "info": {
    "title": "PingArthur",
    "version": "0.0.0-pre-alpha",
    "description": "Minimal uptime monitoring. Prove URL ownership with a header echo, then\npoll for the latest status. See README.md for context, CLAUDE.md for the\narchitecture, BACKLOG.md for parked features.\n\n**This document is canonical for limits, timeouts, and endpoint shapes.**\nCode constants must match it; if they drift, fix the code, not the spec.\n\n**End-to-end flow.** The header echo is both the ownership proof at bind\ntime *and* the ongoing liveness signal — there is no separate \"verify\nonce, then trust\" phase.\n\n1. `POST /api/v1/tokens` → receive a `pa_<...>` bearer.\n2. **Configure the target first.** Make every HEAD response from the URL\n   you intend to monitor return an `X-PingArthur-Token` header whose\n   value equals the bearer from step 1, and deploy that change before\n   step 3. The header value is the bearer verbatim — no `Bearer ` prefix,\n   no JSON wrapping, no transformation. Same value goes back on every\n   HEAD response forever; it is not a one-shot challenge.\n3. `POST /api/v1/monitors` with the bearer in `Authorization` and the\n   target URL in the body. The server immediately HEAD-probes the URL\n   and records the result. If you skipped step 2, the monitor is still\n   created — see `bindMonitor` for the `token_mismatch` grace window.\n4. `GET /api/v1/status` (same bearer) returns the latest observation.\n5. **Steady state:** keep returning the `X-PingArthur-Token: <bearer>`\n   header on every HEAD response, indefinitely. The 60-minute checker\n   re-validates the echo on every cycle; 24 h with the echo missing or\n   mismatched auto-stops and deletes the monitor.\n\n**Stopping monitoring / deleting your data.** The protocol is the\ndeletion API: remove the `X-PingArthur-Token` header from your URL's HEAD\nresponse, and within 24 h the monitor and all stored data — including\nthe encrypted Atmosphere app password, if any — are deleted from etcd\n(no backups). There is no separate `DELETE` endpoint by design: the\nbearer token is a capability URL, intentionally non-secret, so granting\nit delete authority would let anyone who saw the polling URL evict the\nmonitor. URL-echo ownership is the trust we honor.\n\n**Operator-only surfaces (deliberately out of scope):**\n`PATCH /admin/v1/monitors` and `GET /metrics` are served by the pingarthur\nbinary but aren't reverse-proxied to the public internet — reachable only\nfrom inside a node (SSH moat, or tailscale0 for `/metrics` on port 9090).\nSee `admin.go` and `supervision.go` for details; don't generate clients\nagainst them from this spec.\n\n**Ambient limits.** Encoded here because OpenAPI 3.1 has no first-class\nsection for \"ambient\" limits; canonical values live in `main.go` (server)\nand `verify.go` (probe).\n\nInbound (HTTP server):\n- read + idle timeout: 10 s\n- write timeout: 15 s (> outbound probe's 10 s deadline, so a timed-out\n  verify has headroom to respond)\n- request body: 4 KiB (over → 413; bounds BindRequest.url)\n- request headers: 16 KiB\n\nOutbound (HEAD probe of monitored URL):\n- total request deadline: 10 s\n- DNS resolution: 3 s\n- TLS handshake: 3 s\n- response-header read deadline: 5 s\n- response-header total size: 16 KiB (over → rejected as \"other\"; defends\n  against header floods)\n- redirects followed: never (3xx surfaces as the recorded status)\n- IP family: IPv4 only\n- blocked CIDRs: 127/8, 10/8, 172.16/12, 192.168/16, 169.254/16, 100.64/10,\n  0/8, 224/4, 240/4\n- X-Health-Status retained: first 280 runes, valid UTF-8 only (raw bytes\n  scrubbed of invalid sequences before truncation). Cap is rune-based so\n  the recorded value is drop-in postable to a Bluesky post (which is\n  bounded by 300 graphemes / 3000 bytes, and runes ≥ graphemes for any\n  string)\n- X-PingArthur-Token compared: constant-time, never logged or stored\n- User-Agent: PingArthur/1.0 (+https://pingarthur.com)\n\nCaps:\n- monitors per eTLD+1: 10\n- global monitors: MAX_MONITORS env, default 10000\n- token bind window: 1 h after issuance\n- default check interval: 60 min (one-shot random offset at bind, then on a\n  fixed grid)\n- auto-stop after token-header gone: 24 h\n- change-history ring buffer per monitor: N=10\n",
    "license": {
      "name": "MIT",
      "url": "https://opensource.org/licenses/MIT"
    },
    "termsOfService": "https://divepool.social/terms",
    "contact": {
      "name": "PingArthur",
      "url": "https://bsky.app/profile/pingarthur.com"
    }
  },
  "externalDocs": {
    "description": "Source repository on Tangled",
    "url": "https://tangled.org/divepool.social/pingarthur"
  },
  "servers": [
    {
      "url": "https://pingarthur.com",
      "description": "public instance"
    }
  ],
  "tags": [
    {
      "name": "tokens",
      "description": "Bearer token issuance and binding."
    },
    {
      "name": "status",
      "description": "Monitor status retrieval."
    },
    {
      "name": "ops",
      "description": "Operational endpoints (health, supervision)."
    },
    {
      "name": "spec",
      "description": "OpenAPI spec self-serve."
    }
  ],
  "paths": {
    "/api/v1/tokens": {
      "post": {
        "tags": [
          "tokens"
        ],
        "operationId": "issueToken",
        "summary": "Issue a fresh bearer token",
        "description": "Returns a stateless `pa_<base64url>` HMAC token. Valid for 1 hour as\na *bind* credential — after that it can no longer be presented to\n`POST /api/v1/monitors`. Once bound, the token's hash becomes the\nmonitor identity and stays usable indefinitely against\n`GET /api/v1/status`.\n\n**Next step:** before calling `POST /api/v1/monitors`, configure the\ntarget URL to return the bearer verbatim in an `X-PingArthur-Token`\nheader on every HEAD response, and keep that header in place for the\nlifetime of the monitor. Deploying after the bind call works too\n(`token_mismatch` grace window — see `bindMonitor`), but is more\nfragile.\n\nNo request body is read. Bodies up to the global limit are accepted\nbut ignored.\n",
        "responses": {
          "200": {
            "description": "Token issued.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TokenResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/monitors": {
      "post": {
        "tags": [
          "tokens"
        ],
        "operationId": "bindMonitor",
        "summary": "Bind a URL to the bearer token",
        "description": "HEAD-probes the URL and records whether `X-PingArthur-Token`\nechoes the bearer, then starts checking on a 60-minute cadence.\n\n**Pre-requisite:** the target URL should already be returning\n`X-PingArthur-Token: <bearer>` (bearer value verbatim, no\n`Bearer ` prefix) on every HEAD response *before* this call.\nThe checker treats the echo as the ongoing liveness signal —\nit is not a one-shot challenge. The header must remain present\non every HEAD response for the life of the monitor; 24 h with\nit missing or mismatched auto-stops and deletes the monitor\n(see top-level description, *Stopping monitoring*).\n\n**Token mismatch at bind is accepted** — the monitor is still\ncreated with `current.error = \"token_mismatch\"` so callers can\nwire up the echo header after binding. The 60-minute check loop\nrevalidates; if the echo never comes online within 24 h of bind,\nthe monitor auto-stops. Other HEAD failures (DNS, TLS, blocked,\ntimeout, etc.) reject with 400 — those indicate the URL itself\nis wrong or unreachable.\n\nThe first check is scheduled at `now + random offset in [0, 5 s)`;\nevery subsequent check lands on the fixed grid\n`scheduled + interval` so monitors stay phase-locked to the\nbind moment. Operators who want a specific phase (e.g. mid-minute\nfor a target with minute-aligned behaviour) bind at the desired\nwall-clock time — the 5 s jitter is small enough that bind moment\ndominates phase.\n\n**Idempotent re-bind** (within the 1 h bind window of the\noriginal token, while a monitor exists for it): updates the URL\nand reschedules. `created_at` and the change-history ring buffer\nare preserved for the life of the monitor. The atmosphere\nbinding is sticky after the first bind — re-bind requests that\ninclude an `atmosphere` block are rejected with 400. To swap\naccounts, stop echoing the token (24 h auto-stop deletes the\nmonitor and the encrypted credential), then re-bind from a fresh\ntoken.\n\nIf an `atmosphere` object is included on a fresh bind, a status\npost is sent to the bound account capturing the current\nobservation tuple — including `token_mismatch` if the echo\nisn't up yet.\n",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BindRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Monitor bound (may be pending token echo — see description).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BindResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid URL (non-HTTPS, bare IP, malformed), or non-token-mismatch verify failure (dns/tls/blocked/timeout/refused/other), or atmosphere setup failed (empty-repo check, bad app password, unresolvable handle), or atmosphere block supplied on idempotent re-bind (atmosphere is sticky after first bind)."
          },
          "401": {
            "description": "Missing or invalid bearer token."
          },
          "413": {
            "description": "Request body exceeds the 4 KiB limit."
          },
          "429": {
            "description": "Per-domain cap (≥10 active monitors per eTLD+1) or global cap reached."
          },
          "503": {
            "description": "Bind-pending lease (~30 s TTL) expired mid-verify. Should not\nhappen under the 10 s outbound probe deadline; safe to retry.\n"
          }
        }
      }
    },
    "/api/v1/status": {
      "get": {
        "tags": [
          "status"
        ],
        "operationId": "getMonitorStatus",
        "summary": "Latest observation + recent change history",
        "description": "Returns the most recent observation plus up to 10 recent status\nchanges. **Assume this data is public** — anyone with the bearer\ntoken can fetch it.\n",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Current state.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StatusResponse"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid bearer token."
          },
          "404": {
            "description": "No monitor bound to this token (or auto-stopped after 24 h of missing header echo)."
          }
        }
      }
    },
    "/status": {
      "get": {
        "tags": [
          "ops"
        ],
        "operationId": "healthcheck",
        "summary": "Supervisor health probe (no auth)",
        "description": "Returns 200 if the local etcd member is reachable from this node,\n503 otherwise. Polled by Oh Dear (external) and used by the DNS\nround-robin clients to evict dead nodes.\n",
        "responses": {
          "200": {
            "description": "Local node healthy.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string",
                  "example": "ok\n"
                }
              }
            }
          },
          "503": {
            "description": "Local etcd unreachable."
          }
        }
      }
    },
    "/api/v1/openapi": {
      "get": {
        "tags": [
          "spec"
        ],
        "operationId": "getOpenAPI",
        "summary": "This OpenAPI document",
        "description": "Returns the embedded OpenAPI spec as JSON. Link-preview bots\n(Twitterbot, Cardyb, facebookexternalhit) get an HTML page with\nOpen Graph tags instead so a shared link renders nicely.\n",
        "responses": {
          "200": {
            "description": "Spec document.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "description": "OpenAPI 3.0 document."
                }
              },
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/openapi.json": {
      "get": {
        "tags": [
          "spec"
        ],
        "operationId": "getOpenAPIJSON",
        "summary": "This OpenAPI document (JSON, no UA sniffing)",
        "responses": {
          "200": {
            "description": "Spec document.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "description": "OpenAPI 3.0 document."
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/openapi.html": {
      "get": {
        "tags": [
          "spec"
        ],
        "operationId": "getOpenAPIHTML",
        "summary": "Open-Graph preview page for /api/v1/openapi",
        "responses": {
          "200": {
            "description": "OG-tagged HTML.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/openapi.jpg": {
      "get": {
        "tags": [
          "spec"
        ],
        "operationId": "getOpenAPIImage",
        "summary": "Open-Graph thumbnail referenced by /api/v1/openapi.html",
        "responses": {
          "200": {
            "description": "JPEG image, 1000x1000.",
            "content": {
              "image/jpeg": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "pa_<base64url>"
      }
    },
    "schemas": {
      "TokenResponse": {
        "type": "object",
        "required": [
          "token",
          "expires_at"
        ],
        "properties": {
          "token": {
            "type": "string",
            "example": "pa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "Bind-window expiry (now + 1h)."
          }
        }
      },
      "BindRequest": {
        "type": "object",
        "required": [
          "url"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "maxLength": 4000,
            "description": "HTTPS URL of the resource to monitor. Bare IPs are rejected.\n**Length cap is a derivative of the 4 KiB request-body limit**:\nanything over the body cap is refused with 413 before parsing,\nso the JSON envelope leaves a few hundred bytes of headroom over\nthis maxLength. The explicit cap is here to make the limit\ndiscoverable from the spec.\n",
            "example": "https://www.example.com/"
          },
          "atmosphere": {
            "$ref": "#/components/schemas/AtmosphereBindRequest"
          }
        }
      },
      "AtmosphereBindRequest": {
        "type": "object",
        "required": [
          "handle",
          "app_password"
        ],
        "description": "Optional. Attach an ATProto account so each observation-tuple\nchange is posted as a Bluesky post (`app.bsky.feed.post` record)\nto the account's repo. Posts render in the Bluesky app and any\nAppView that ingests Bluesky lexicons. The PDS itself can be\nBluesky's `bsky.social` or any conforming self-hosted PDS — the\nauth and write calls are ATProto-standard.\n\n**Post body is your `X-Health-Status` header verbatim.** No\nmonitor URL, no diff, no decoration — whatever the target\nreturned in `X-Health-Status` (after the 280-rune cap) is the\npost text. This makes the message fully customizable by the\ntarget itself; if you want context, put it in the header.\n\nWhen the header is absent (network error, timeout, status-only\nchanges, or a healthy target that hasn't set `X-Health-Status`),\nPingArthur posts a descriptive fallback that names what failed\nand whether it's user-fixable, persistent, or transient — enough\ncontext that an operator reading the post on Bluesky can decide\nto debug their config or wait it out. Bluesky requires non-empty\npost text, so the fallback always produces something postable.\n\n**Hidden tags** (`app.bsky.feed.post.tags`) are attached to\nevery post: `PingArthur` (lets anyone filter all posts from\nthis service) and the monitor's token-hash (lets a viewer\ngroup posts per-monitor without leaking the bearer — the hash\nis SHA-256 of the bearer, irreversible). Tags are metadata,\nnot rendered inline in post text.\n\n**Use a dedicated account.** The bind rejects (400) if the\naccount's `app.bsky.feed.post` collection is non-empty, to\nprotect users from accidentally linking their main identity.\n\nCredentials verified at bind time via\n`com.atproto.server.createSession`. App passwords are encrypted\nat rest with AES-256-GCM; the server never returns the password\nor its ciphertext. **The atmosphere binding cannot be changed\nafter bind — to swap accounts, delete the monitor and re-bind\nfrom a fresh token.**\n",
        "properties": {
          "handle": {
            "type": "string",
            "maxLength": 253,
            "description": "ATProto handle (e.g. `status-foo.bsky.social` or a custom\ndomain). Resolved to DID at bind time via the public\nappview; stored as DID + resolved PDS URL.\n",
            "example": "status-example.bsky.social"
          },
          "app_password": {
            "type": "string",
            "maxLength": 128,
            "writeOnly": true,
            "description": "App password for the account. **Do not use your main\naccount password.** On Bluesky, generate one at Settings →\nApp Passwords (ideally without DM access); they have the\nform `xxxx-xxxx-xxxx-xxxx`. On other PDSes, use whichever\ncredential the account exposes for\n`com.atproto.server.createSession` — the server-side check\nis the standard ATProto session call, no Bluesky-specific\nformat is required.\n"
          }
        }
      },
      "BindResponse": {
        "type": "object",
        "required": [
          "url",
          "next_check_at"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri"
          },
          "next_check_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the next check is scheduled. On a fresh bind this is at\n`now + random offset in [0, 5 s)`. On idempotent re-bind it\nstays on the original phase grid.\n"
          }
        }
      },
      "Observation": {
        "type": "object",
        "required": [
          "http_status",
          "health_status",
          "error",
          "observed_at"
        ],
        "properties": {
          "http_status": {
            "type": "integer",
            "description": "HTTP status code from the HEAD response (0 if no response)."
          },
          "health_status": {
            "type": "string",
            "description": "Value of the target's `X-Health-Status` response header, scrubbed\nof invalid UTF-8 and truncated to the first 280 runes (worst-case\n1120 bytes for 4-byte runes). **Targets should put the most\nimportant information first** — anything beyond rune 280 is\ndropped before we ever record it. The cap is sized so the\nrecorded value is drop-in postable to a Bluesky post when an\nAtmosphere binding is attached (Bluesky posts are bounded by\n300 graphemes / 3000 bytes; runes ≥ graphemes always).\n"
          },
          "error": {
            "type": "string",
            "enum": [
              "",
              "token_mismatch",
              "blocked",
              "dns",
              "tls",
              "timeout",
              "refused",
              "other"
            ],
            "description": "Normalized error category. Empty on success.\n\n- `` (empty): HEAD returned a response *and* `X-PingArthur-Token` matched the bearer.\n- `token_mismatch`: HEAD returned a response, but the echo header was missing or wrong. Only category that does **not** reject at bind; counts toward the 24 h auto-stop clock.\n- `blocked`: hostname resolved to a private/loopback/CGNAT/multicast IP — see SSRF blocklist in the top-level description.\n- `dns`: hostname did not resolve (NXDOMAIN, no IPv4 record, etc.).\n- `tls`: TLS handshake failed (bad cert, hostname mismatch, protocol error).\n- `timeout`: probe exceeded the 10 s deadline somewhere along the way.\n- `refused`: TCP connection refused.\n- `other`: anything else (response-header size cap exceeded, malformed HTTP, etc.).\n"
          },
          "observed_at": {
            "type": "string",
            "format": "date-time",
            "description": "Timestamp of the most recent probe. For `current`, this\nadvances on every check — even when the observation tuple\ndidn't change — so polling clients can tell the monitor\nis still alive. For `history` entries, it's the time of\nthe observation as it stood when it was replaced.\n"
          }
        }
      },
      "StatusResponse": {
        "type": "object",
        "required": [
          "url",
          "current",
          "history"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri"
          },
          "current": {
            "$ref": "#/components/schemas/Observation"
          },
          "history": {
            "type": "array",
            "maxItems": 10,
            "description": "Up to 10 most recent observations where the (status, health, error) tuple changed. Newest first.",
            "items": {
              "$ref": "#/components/schemas/Observation"
            }
          },
          "atmosphere": {
            "$ref": "#/components/schemas/AtmosphereStatus"
          }
        }
      },
      "AtmosphereStatus": {
        "type": "object",
        "description": "Present only if an Atmosphere binding was attached at bind time.\nDisplay-only view — the server **never** returns the app password\nor its encrypted form. `last_post_error` is surfaced so clients\ncan tell whether posting is currently failing without having to\nscrape Prometheus.\n",
        "required": [
          "handle"
        ],
        "properties": {
          "handle": {
            "type": "string",
            "description": "The handle supplied at bind (may have since been renamed by the user on Bluesky; the DID is the stable identity we post against)."
          },
          "last_post_at": {
            "type": "string",
            "format": "date-time",
            "description": "Timestamp of the most recent successful atmosphere post; absent if none have succeeded yet."
          },
          "last_post_error": {
            "type": "string",
            "maxLength": 200,
            "description": "Short description of the most recent post failure. Absent on success."
          },
          "last_post_error_at": {
            "type": "string",
            "format": "date-time",
            "description": "Timestamp of the last_post_error. Absent if last_post_error is absent."
          }
        }
      }
    }
  }
}
