{
  "openapi": "3.1.0",
  "info": {
    "title": "Tenjin API",
    "version": "0.1.0",
    "contact": {
      "email": "hello@tenjin.blog"
    },
    "x-guidance": "Pay-per-article publishing on Base (USDC, eip155:8453). READ a paid essay: GET /api/read/{handle}/{slug} answers 402 with an x402 challenge; pay `exact` USDC then retry with the PAYMENT-SIGNATURE header. A free post returns 200 in-band. PUBLISH or manage an account: sign a SIGN-IN-WITH-X (SIWX, CAIP-122) header instead of an API key. The payable essays are enumerated as concrete resources at https://tenjin.blog/.well-known/x402.json. Full worked examples for both flows: https://tenjin.blog/llms.txt.",
    "description": "The conventional JSON surface of Tenjin, an x402-native publishing platform on Base.\n\nThis spec covers the SIWX-gated authoring/account CRUD plus the public discovery reads\n(article/creator/tag directories + full-text search) — the surface deterministic tooling (codegen,\nPostman, OpenAPI→MCP converters) and x402 indexers (x402scan) consume. It is a *secondary*\nsurface: read https://tenjin.blog/llms.txt first for the canonical narrative guide and wallet options.\n\nThe x402 paid read IS declared (GET /api/read/<handle>/<slug>, tagged x-payment-info + a 402\nresponse) so indexers see the paid surface — but two things still cannot be expressed in vanilla\nOpenAPI, so https://tenjin.blog/llms.txt stays canonical for them:\n  • The pay-then-retry mechanics: a 402 challenge → sign an `exact` USDC payment → retry with the\n    PAYMENT-SIGNATURE header. The 402 body + PAYMENT-REQUIRED header are machine-readable; see\n    https://tenjin.blog/llms.txt and https://docs.x402.org.\n  • Constructing the SIGN-IN-WITH-X header (build a CAIP-122 message → sign EIP-191 → base64).\n    OpenAPI can declare the header (below) but not how to mint it — https://tenjin.blog/llms.txt has the\n    full worked example.\n\nThe HTML reader (GET /a/<handle>/<slug>) is a content-negotiated alias of this JSON read and is\nnot modeled separately."
  },
  "servers": [
    {
      "url": "https://tenjin.blog"
    }
  ],
  "paths": {
    "/api/posts": {
      "post": {
        "operationId": "createPost",
        "summary": "Create (and by default publish) a post",
        "description": "Auto-provisions the creator row on a wallet's first post. The nonce in the SIWX header is single-use.",
        "security": [
          {
            "siwx": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PostCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OwnPost"
                }
              }
            }
          },
          "400": {
            "description": "Validation failed (`validation_failed`); `details` carries the field errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid SIWX proof (`unauthenticated`). The `WWW-Authenticate: SIWX error=\"...\"` header classifies the failure; re-sign with a fresh nonce + issuedAt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Conflict — `handle_taken`, `handle_cooling_down`, or `account_deleted`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited (`rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "get": {
        "operationId": "listOwnPosts",
        "summary": "List your own posts (cursor-paginated)",
        "security": [
          {
            "siwx": []
          }
        ],
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "draft",
                "published",
                "unlisted",
                "deleted"
              ]
            },
            "description": "Filter by status; defaults to all non-deleted."
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Last item id from the previous page."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 20
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of your posts.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OwnPostsPage"
                }
              }
            }
          },
          "400": {
            "description": "Validation failed (`validation_failed`); `details` carries the field errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid SIWX proof (`unauthenticated`). The `WWW-Authenticate: SIWX error=\"...\"` header classifies the failure; re-sign with a fresh nonce + issuedAt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited (`rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/posts/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "get": {
        "operationId": "getOwnPost",
        "summary": "Fetch one of your own posts",
        "security": [
          {
            "siwx": []
          }
        ],
        "responses": {
          "200": {
            "description": "The post.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OwnPost"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid SIWX proof (`unauthenticated`). The `WWW-Authenticate: SIWX error=\"...\"` header classifies the failure; re-sign with a fresh nonce + issuedAt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Not found, or not yours (`post_not_found` / `image_not_found`) — owner-scoped routes do not distinguish the two, to avoid an existence leak.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited (`rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "updatePost",
        "summary": "Update one of your own posts",
        "description": "Partial update; every field is optional. The nonce is single-use.",
        "security": [
          {
            "siwx": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PostUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated post.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OwnPost"
                }
              }
            }
          },
          "400": {
            "description": "Validation failed (`validation_failed`); `details` carries the field errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid SIWX proof (`unauthenticated`). The `WWW-Authenticate: SIWX error=\"...\"` header classifies the failure; re-sign with a fresh nonce + issuedAt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Not found, or not yours (`post_not_found` / `image_not_found`) — owner-scoped routes do not distinguish the two, to avoid an existence leak.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Conflict — `handle_taken`, `handle_cooling_down`, or `account_deleted`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited (`rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "delete": {
        "operationId": "deletePost",
        "summary": "Soft-delete one of your own posts",
        "description": "Idempotent — deleting an already-deleted post still returns 204. The nonce is single-use.",
        "security": [
          {
            "siwx": []
          }
        ],
        "responses": {
          "204": {
            "description": "Deleted (no body)."
          },
          "401": {
            "description": "Missing or invalid SIWX proof (`unauthenticated`). The `WWW-Authenticate: SIWX error=\"...\"` header classifies the failure; re-sign with a fresh nonce + issuedAt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Not found, or not yours (`post_not_found` / `image_not_found`) — owner-scoped routes do not distinguish the two, to avoid an existence leak.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited (`rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/me": {
      "get": {
        "operationId": "getMe",
        "summary": "Get the connected wallet's creator profile",
        "security": [
          {
            "siwx": []
          }
        ],
        "responses": {
          "200": {
            "description": "The profile (creator may be null).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MeResponse"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid SIWX proof (`unauthenticated`). The `WWW-Authenticate: SIWX error=\"...\"` header classifies the failure; re-sign with a fresh nonce + issuedAt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited (`rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "put": {
        "operationId": "upsertMe",
        "summary": "Create or update your creator profile",
        "description": "Also the handle claim/rename path. The nonce is single-use.",
        "security": [
          {
            "siwx": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Profile"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The upserted profile.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MeResponse"
                }
              }
            }
          },
          "400": {
            "description": "Validation failed (`validation_failed`); `details` carries the field errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid SIWX proof (`unauthenticated`). The `WWW-Authenticate: SIWX error=\"...\"` header classifies the failure; re-sign with a fresh nonce + issuedAt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Conflict — `handle_taken`, `handle_cooling_down`, or `account_deleted`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited (`rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/auth/logout": {
      "post": {
        "operationId": "logout",
        "summary": "Revoke the current SIWX nonce (explicit logout)",
        "description": "Stateless auth keeps no server session to drop; this writes the nonce to revoked_nonces so a captured proof can't be replayed. withAuth-gated — only the holder of a valid proof can revoke it.",
        "security": [
          {
            "siwx": []
          }
        ],
        "responses": {
          "204": {
            "description": "Logged out; the nonce is revoked (no body)."
          },
          "401": {
            "description": "Missing or invalid SIWX proof (`unauthenticated`). The `WWW-Authenticate: SIWX error=\"...\"` header classifies the failure; re-sign with a fresh nonce + issuedAt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited (`rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/me/stats": {
      "get": {
        "operationId": "getMyStats",
        "summary": "This-month earnings + paid-read count",
        "security": [
          {
            "siwx": []
          }
        ],
        "responses": {
          "200": {
            "description": "Dashboard scalars.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Stats"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid SIWX proof (`unauthenticated`). The `WWW-Authenticate: SIWX error=\"...\"` header classifies the failure; re-sign with a fresh nonce + issuedAt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited (`rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/me/events": {
      "get": {
        "operationId": "listMyEvents",
        "summary": "Your settled-sale feed (newest first, cursor-paginated)",
        "description": "Private to the connected wallet: scoped to your posts via the SIWX proof, never a query param. One entry per settled payment (a PAID read; free-preview views are not tracked); the buyer wallet is not exposed. Poll it and diff against the newest createdAt you have seen to notice new sales; GET /api/me/stats gives the this-month aggregates. The poll request (no cursor) returns a weak ETag; send it back as If-None-Match and an unchanged feed answers 304 with no body.",
        "security": [
          {
            "siwx": []
          }
        ],
        "parameters": [
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Opaque keyset cursor from the previous page."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 20
            }
          },
          {
            "name": "If-None-Match",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "The weak ETag from a prior poll. If the feed head is unchanged the response is 304 with no body (poll-cheap path); only meaningful without a cursor."
          }
        ],
        "responses": {
          "200": {
            "description": "A page of your sale events.",
            "headers": {
              "ETag": {
                "description": "Weak validator for the feed head (newest sale). Echo it as If-None-Match on the next poll. Present on the no-cursor poll response.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EventsPage"
                }
              }
            }
          },
          "304": {
            "description": "Feed unchanged since your If-None-Match ETag (no body)."
          },
          "400": {
            "description": "Validation failed (`validation_failed`); `details` carries the field errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid SIWX proof (`unauthenticated`). The `WWW-Authenticate: SIWX error=\"...\"` header classifies the failure; re-sign with a fresh nonce + issuedAt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited (`rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/library": {
      "get": {
        "operationId": "listLibrary",
        "summary": "Essays the connected wallet has paid to read",
        "description": "Private to the connected wallet — the payer is derived from the SIWX proof, never a query param.",
        "security": [
          {
            "siwx": []
          }
        ],
        "parameters": [
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Opaque keyset cursor from the previous page."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 20
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of your library.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LibraryPage"
                }
              }
            }
          },
          "400": {
            "description": "Validation failed (`validation_failed`); `details` carries the field errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid SIWX proof (`unauthenticated`). The `WWW-Authenticate: SIWX error=\"...\"` header classifies the failure; re-sign with a fresh nonce + issuedAt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited (`rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/read/{handle}/{slug}/markdown": {
      "get": {
        "operationId": "downloadArticleMarkdown",
        "summary": "Download an essay's source Markdown (the author's body_md)",
        "description": "Returns the essay's raw source markdown as a `text/markdown` attachment — the same essay you can read, as a file you can keep. NOT an x402 surface: it never issues a 402, so it cannot double-charge. A free essay is open; a paid essay is served ONLY to a SIGN-IN-WITH-X-authed wallet that already holds a payment for THIS post (the same returning-buyer entitlement as GET /api/read/<handle>/<slug>). To PAY for a paid essay, run the x402 read loop there first.",
        "security": [
          {},
          {
            "siwx": []
          }
        ],
        "parameters": [
          {
            "name": "handle",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Creator word-handle or 0x wallet address."
          },
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The essay's source markdown: a small YAML frontmatter block (title, author, source) then the verbatim body. `Content-Disposition: attachment`.",
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid SIWX proof (`unauthenticated`). The `WWW-Authenticate: SIWX error=\"...\"` header classifies the failure; re-sign with a fresh nonce + issuedAt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Authenticated, but this wallet has not paid for this post (`not_entitled`). Re-signing will not help — run the x402 read loop to pay first.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Not found, or not yours (`post_not_found` / `image_not_found`) — owner-scoped routes do not distinguish the two, to avoid an existence leak.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/images": {
      "post": {
        "operationId": "uploadImage",
        "summary": "Upload an image (raw bytes for agents, or the browser Blob handshake)",
        "description": "Two shapes share this route, dispatched on Content-Type. AGENTS: send the RAW image bytes with an image/* Content-Type (image/jpeg, image/png, image/gif, image/webp) — one SIWX-gated call, no @vercel/blob SDK. Optional alt text via an X-Image-Alt header. Capped at 4 MB; the bytes are magic-byte-checked against the declared type (a mislabeled file or an SVG is rejected). Returns { imageId, url } where url is the stable GET /api/images/{id} address — embed it in a post bodyMd as ![alt](url) (the first free-preview body image automatically becomes the cover) or set it as your avatarImageId. BROWSERS: drive the @vercel/blob client-upload handshake instead — an application/json body with a `type` discriminant (blob.generate-client-token / record-upload / blob.upload-completed); the bytes go client-direct to Blob (5 MB). Agents do not need this path.",
        "security": [
          {
            "siwx": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "image/png": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            },
            "image/jpeg": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            },
            "image/gif": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            },
            "image/webp": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            },
            "application/json": {
              "schema": {
                "anyOf": [
                  {
                    "$ref": "#/components/schemas/ImageRecordUpload"
                  },
                  {
                    "type": "object",
                    "description": "A @vercel/blob HandleUploadBody (generate-client-token / upload-completed), carrying its own `type` discriminant."
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The agent raw-upload returns { imageId, url }; the handshake returns its token / recorded { imageId, url }.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "imageId": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "url": {
                      "type": "string",
                      "description": "The stable /api/images/{id} ref to embed or persist."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Validation failed (`validation_failed`); `details` carries the field errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid SIWX proof (`unauthenticated`). The `WWW-Authenticate: SIWX error=\"...\"` header classifies the failure; re-sign with a fresh nonce + issuedAt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited (`rate_limited`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/images/{id}": {
      "get": {
        "operationId": "getImage",
        "summary": "Serve an image by id (public, 302 redirect)",
        "description": "Public — no SIWX. Redirects (302) to the immutable Vercel Blob CDN URL; CORS-open.",
        "security": [],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Redirect to the CDN URL (Location header)."
          },
          "400": {
            "description": "Validation failed (`validation_failed`); `details` carries the field errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Not found, or not yours (`post_not_found` / `image_not_found`) — owner-scoped routes do not distinguish the two, to avoid an existence leak.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/health": {
      "get": {
        "operationId": "getHealth",
        "summary": "Liveness probe",
        "description": "Public — no SIWX.",
        "security": [],
        "responses": {
          "200": {
            "description": "Up.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/api/articles": {
      "get": {
        "operationId": "listArticles",
        "summary": "Article directory + full-text search",
        "description": "The public article feed: every published article from every non-deleted writer, newest-first, cursor-paginated. Compose three optional filters (AND): `q` (leak-safe full-text search over title + excerpt + tags — NOT a body search), `tag` (a tag slug), and `creator` (a word-handle or 0x address). Preview-only — no paid body or below-paywall image is ever returned.",
        "security": [],
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 256
            },
            "description": "Full-text search over title + excerpt + tags, ts_rank-ordered. Blank ⇒ unfiltered directory."
          },
          {
            "name": "tag",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 128
            },
            "description": "A tag slug to scope to."
          },
          {
            "name": "creator",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 128
            },
            "description": "A creator word-handle or 0x address; unknown/soft-deleted ⇒ 404."
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 256
            },
            "description": "Opaque keyset cursor from the previous page. The format differs between directory and search modes; a malformed cursor ⇒ 400."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of articles.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ArticlesPage"
                }
              }
            }
          },
          "400": {
            "description": "Validation failed (`validation_failed`); `details` carries the field errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`creator_not_found` — the `?creator=` handle/address is unknown or soft-deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/creators": {
      "get": {
        "operationId": "listCreators",
        "summary": "Writer directory",
        "description": "Every non-deleted creator, alphabetical by handle then wallet, cursor-paginated. Each row carries a real articleCount (published only; unlisted is hidden from discovery). Public.",
        "security": [],
        "parameters": [
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Opaque pagination cursor — pass the previous page's `nextCursor` back verbatim. Malformed ⇒ 400."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of creators.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreatorsPage"
                }
              }
            }
          },
          "400": {
            "description": "Validation failed (`validation_failed`); `details` carries the field errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/creators/{handle}": {
      "parameters": [
        {
          "name": "handle",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          },
          "description": "A creator word-handle OR 0x address."
        }
      ],
      "get": {
        "operationId": "getCreatorProfile",
        "summary": "One creator's profile + their article feed",
        "description": "Resolves a word-handle or 0x address to one creator, then returns their public profile + a cursor-paginated feed of their articles (newest-first, the full feed — no 100-cap). Preview-only.",
        "security": [],
        "parameters": [
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Keyset cursor from the previous page; malformed ⇒ 400."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The creator + a page of their articles.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreatorProfile"
                }
              }
            }
          },
          "400": {
            "description": "Validation failed (`validation_failed`); `details` carries the field errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`creator_not_found` — the handle/address is unknown or soft-deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/tags": {
      "get": {
        "operationId": "listTags",
        "summary": "Tags in use with article counts",
        "description": "Every tag carried by ≥1 visible article, alphabetical by slug, cursor-paginated. Orphan / zero-count tags are excluded by the join. Public.",
        "security": [],
        "parameters": [
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Opaque pagination cursor — pass the previous page's `nextCursor` back verbatim. Malformed ⇒ 400."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of tags.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TagsPage"
                }
              }
            }
          },
          "400": {
            "description": "Validation failed (`validation_failed`); `details` carries the field errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/read/{handle}/{slug}": {
      "parameters": [
        {
          "name": "handle",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          },
          "description": "A creator word-handle OR 0x address."
        },
        {
          "name": "slug",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          },
          "description": "The article slug (case-insensitive)."
        }
      ],
      "get": {
        "operationId": "readArticle",
        "summary": "Read an article (x402 pay-per-read)",
        "description": "The pay-per-read surface. A FREE post (price \"0\") returns 200 with the full body in-band. A PAID post returns a 402 challenge (the PAYMENT-REQUIRED header carries the x402 requirements; the JSON body is the leak-free preview below) until an `exact` USDC payment on Base settles, then 200 with the paid body. A returning buyer who proves a prior payment via the SIGN-IN-WITH-X header re-reads at 200 without paying again. Constructing the PAYMENT-SIGNATURE retry is not expressible in OpenAPI — see https://tenjin.blog/llms.txt.",
        "x-payment-info": {
          "price": {
            "mode": "dynamic",
            "currency": "USD"
          },
          "protocols": [
            {
              "x402": {}
            }
          ]
        },
        "responses": {
          "200": {
            "description": "The unlocked essay — free (in-band), an entitled re-read, or post-payment.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ReadArticleUnlocked"
                }
              }
            }
          },
          "402": {
            "description": "Payment Required — a paid post not yet paid for. The PAYMENT-REQUIRED response header carries the x402 challenge (scheme `exact`, network eip155:8453, the USDC asset, the per-article amount + payTo split address); the JSON body is the leak-free preview.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ReadArticlePreview"
                }
              }
            }
          },
          "404": {
            "description": "`post_not_found` — no published/unlisted post at this handle+slug. Drafts, deleted posts, and soft-deleted creators all resolve here, never to a 402.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "siwx": {
        "type": "apiKey",
        "in": "header",
        "name": "SIGN-IN-WITH-X",
        "description": "A base64-encoded CAIP-122 message signed by your wallet (SIWX — Sign-In-With-X), sent on the FIRST request. There is no account, API key, or server-issued challenge: the chainId must be eip155:8453 (Base), the domain must be this site's host, and the nonce is CLIENT-minted and single-use on every state-changing route (the server burns it). Build it with createSIWxMessage → signMessage → encodeSIWxHeader. OpenAPI cannot express that construction — see https://tenjin.blog/llms.txt for the complete worked example and which wallets can sign it."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "Stable error envelope returned by every failing route (the `requestId` is on the `x-request-id` response header, not in the body).",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string",
                "description": "Stable machine code, e.g. \"validation_failed\", \"post_not_found\"."
              },
              "message": {
                "type": "string"
              },
              "details": {
                "description": "Optional structured context (e.g. a zod flatten() on validation_failed)."
              }
            },
            "required": [
              "code",
              "message"
            ]
          }
        },
        "required": [
          "error"
        ]
      },
      "PostCreate": {
        "type": "object",
        "properties": {
          "title": {
            "default": "",
            "type": "string",
            "maxLength": 200
          },
          "bodyMd": {
            "default": "",
            "type": "string",
            "maxLength": 200000
          },
          "excerpt": {
            "type": "string",
            "maxLength": 500
          },
          "tags": {
            "maxItems": 5,
            "type": "array",
            "items": {
              "type": "string",
              "minLength": 1,
              "maxLength": 50
            }
          },
          "price": {
            "type": "string",
            "pattern": "^(0|[1-9]\\d{0,12})$"
          },
          "handle": {
            "type": "string",
            "pattern": "^[a-z0-9-]{2,32}$"
          },
          "status": {
            "default": "published",
            "type": "string",
            "enum": [
              "draft",
              "published",
              "unlisted"
            ]
          }
        },
        "additionalProperties": false
      },
      "PostUpdate": {
        "type": "object",
        "properties": {
          "title": {
            "type": "string",
            "maxLength": 200
          },
          "bodyMd": {
            "type": "string",
            "maxLength": 200000
          },
          "excerpt": {
            "type": "string",
            "maxLength": 500
          },
          "tags": {
            "maxItems": 5,
            "type": "array",
            "items": {
              "type": "string",
              "minLength": 1,
              "maxLength": 50
            }
          },
          "price": {
            "type": "string",
            "pattern": "^(0|[1-9]\\d{0,12})$"
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "published",
              "unlisted"
            ]
          }
        },
        "additionalProperties": false
      },
      "Profile": {
        "type": "object",
        "properties": {
          "handle": {
            "type": "string",
            "pattern": "^[a-z0-9-]{2,32}$"
          },
          "displayName": {
            "type": "string",
            "maxLength": 100
          },
          "bio": {
            "type": "string",
            "maxLength": 280
          },
          "defaultPrice": {
            "type": "string",
            "pattern": "^(0|[1-9]\\d{0,12})$"
          },
          "showHumanButton": {
            "type": "boolean"
          },
          "avatarImageId": {
            "anyOf": [
              {
                "type": "string",
                "format": "uuid",
                "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "additionalProperties": false
      },
      "ImageRecordUpload": {
        "type": "object",
        "properties": {
          "imageId": {
            "type": "string",
            "format": "uuid",
            "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
          },
          "blobUrl": {
            "type": "string",
            "format": "uri"
          },
          "pathname": {
            "type": "string",
            "minLength": 1,
            "maxLength": 1024
          },
          "contentType": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255
          },
          "altText": {
            "type": "string",
            "maxLength": 300
          }
        },
        "required": [
          "imageId",
          "blobUrl",
          "pathname",
          "contentType"
        ],
        "additionalProperties": false
      },
      "Creator": {
        "type": "object",
        "description": "A writer profile (lib/db/schema/selectors.ts publicCreatorColumns). `handle` is null until a word-handle is claimed.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "handle": {
            "type": [
              "string",
              "null"
            ]
          },
          "displayName": {
            "type": [
              "string",
              "null"
            ]
          },
          "walletAddress": {
            "type": "string",
            "description": "The 0x wallet address — the permanent identity."
          },
          "splitAddress": {
            "type": [
              "string",
              "null"
            ]
          },
          "avatarImageId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "defaultPrice": {
            "type": "string",
            "description": "Atomic USDC units (6 decimals) as a digit string. \"500000\" = $0.50."
          },
          "bio": {
            "type": [
              "string",
              "null"
            ]
          },
          "showHumanButton": {
            "type": "boolean"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "walletAddress",
          "defaultPrice"
        ]
      },
      "OwnPost": {
        "type": "object",
        "description": "An owned post as returned by POST /api/posts and GET/PUT /api/posts/{id}.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "creatorId": {
            "type": "string",
            "format": "uuid",
            "description": "The authoring creator — yourself, for an owned post."
          },
          "slug": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "excerpt": {
            "type": "string"
          },
          "bodyMd": {
            "type": "string",
            "description": "Markdown source. A `<!--paywall-->` line marks the free/paid split."
          },
          "bodyHtmlPreview": {
            "type": "string",
            "description": "Rendered free-preview HTML (before the paywall)."
          },
          "bodyHtmlPaid": {
            "type": "string",
            "description": "Rendered full-essay HTML (sanitized)."
          },
          "coverImageId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "price": {
            "type": "string",
            "description": "Atomic USDC units (6 decimals) as a digit string. \"500000\" = $0.50."
          },
          "arbiterId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "published",
              "unlisted",
              "deleted"
            ]
          },
          "publishedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "tagsBlob": {
            "type": "string",
            "description": "Internal denormalized form of `tags` (the raw stored string). Prefer the parsed `tags` array — this field may be dropped from the wire."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Canonical permalink, /a/<handle-or-address>/<slug>."
          },
          "warnings": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Present (POST/PUT only) when the save dropped something non-fatal — e.g. body images that referenced an external/local URL and were removed (only your own /api/images/<id> uploads are kept). Absent on a clean save."
          }
        },
        "required": [
          "id",
          "creatorId",
          "slug",
          "title",
          "price",
          "status",
          "tags",
          "url"
        ]
      },
      "OwnPostListItem": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "slug": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "excerpt": {
            "type": "string"
          },
          "price": {
            "type": "string",
            "description": "Atomic USDC units (6 decimals) as a digit string. \"500000\" = $0.50."
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "published",
              "unlisted",
              "deleted"
            ]
          },
          "coverImageId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "arbiterId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "publishedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          },
          "paidReads": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Lifetime paid reads; null for a draft/deleted row."
          },
          "earnedNet": {
            "type": [
              "string",
              "null"
            ],
            "description": "Lifetime net earnings (atomic USDC); null for a draft/deleted row."
          }
        },
        "required": [
          "id",
          "slug",
          "title",
          "price",
          "status"
        ]
      },
      "OwnPostsPage": {
        "type": "object",
        "description": "Cursor-paginated page. Pass `nextCursor` back as `?cursor=` for the next page; null means the last page.",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OwnPostListItem"
            }
          },
          "nextCursor": {
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "items",
          "nextCursor"
        ]
      },
      "LibraryItem": {
        "type": "object",
        "description": "One essay on the wallet's permanent shelf (lib/library.ts LibraryItem).",
        "properties": {
          "handle": {
            "type": "string",
            "description": "Byline identifier — the writer's word-handle or 0x address."
          },
          "slug": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "amount": {
            "type": "string",
            "description": "Atomic USDC units (6 decimals) as a digit string. \"500000\" = $0.50."
          },
          "txHash": {
            "type": "string",
            "description": "Settlement tx hash, 0x-prefixed hex.",
            "pattern": "^0x[0-9a-f]+$"
          },
          "purchasedAt": {
            "type": "string",
            "format": "date-time",
            "description": "\"Owned since\" — earliest purchase time, ISO 8601 UTC."
          }
        },
        "required": [
          "handle",
          "slug",
          "title",
          "amount",
          "txHash",
          "purchasedAt"
        ]
      },
      "LibraryPage": {
        "type": "object",
        "description": "Cursor-paginated page. Pass `nextCursor` back as `?cursor=` for the next page; null means the last page.",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/LibraryItem"
            }
          },
          "nextCursor": {
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "items",
          "nextCursor"
        ]
      },
      "CreatorEvent": {
        "type": "object",
        "description": "One settled-sale event on your own post (lib/creator-events.ts CreatorSaleEvent). \"Reads\" are PAID reads — a settled payment; free-preview views are not tracked.",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "sale"
            ],
            "description": "Event kind; only \"sale\" today (the discriminant future-proofs the shape)."
          },
          "handle": {
            "type": "string",
            "description": "Byline identifier — your word-handle or 0x address (build /a/<handle>/<slug>)."
          },
          "slug": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "amount": {
            "type": "string",
            "description": "Atomic USDC units (6 decimals) as a digit string. \"500000\" = $0.50."
          },
          "netAmount": {
            "type": "string",
            "description": "Atomic USDC units (6 decimals) as a digit string. \"500000\" = $0.50."
          },
          "txHash": {
            "type": "string",
            "description": "Settlement tx hash, 0x-prefixed hex. The buyer wallet is deliberately not exposed (the feed must not hand back an enumerable buyer roster).",
            "pattern": "^0x[0-9a-f]+$"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time",
            "description": "When the sale settled, ISO 8601 UTC."
          }
        },
        "required": [
          "type",
          "handle",
          "slug",
          "title",
          "amount",
          "netAmount",
          "txHash",
          "createdAt"
        ]
      },
      "EventsPage": {
        "type": "object",
        "description": "Cursor-paginated page. Pass `nextCursor` back as `?cursor=` for the next page; null means the last page.",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CreatorEvent"
            }
          },
          "nextCursor": {
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "items",
          "nextCursor"
        ]
      },
      "MeResponse": {
        "type": "object",
        "properties": {
          "address": {
            "type": "string",
            "description": "The verified (checksummed) wallet address."
          },
          "creator": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Creator"
              },
              {
                "type": "null"
              }
            ],
            "description": "null if this wallet has never authored or set a profile."
          }
        },
        "required": [
          "address",
          "creator"
        ]
      },
      "Stats": {
        "type": "object",
        "description": "This-month dashboard scalars (lib/dashboard-stats.ts).",
        "properties": {
          "earningsThisMonth": {
            "type": "string",
            "description": "Net earnings this UTC month, atomic USDC string (\"0\" when none)."
          },
          "paidReadsThisMonth": {
            "type": "integer"
          }
        },
        "required": [
          "earningsThisMonth",
          "paidReadsThisMonth"
        ]
      },
      "ArticleTag": {
        "type": "object",
        "description": "A tag label + its DB-authoritative slug (the `?tag=` filter value).",
        "properties": {
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string"
          }
        },
        "required": [
          "name",
          "slug"
        ]
      },
      "ArticleListItem": {
        "type": "object",
        "description": "A preview-only article row (lib/articles.ts ArticleListItem) — the same shape the directory, search, feed, and manifests emit. Never carries a paid body.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "slug": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "excerpt": {
            "type": "string"
          },
          "price": {
            "type": "string",
            "description": "Atomic USDC units (6 decimals) as a digit string. \"500000\" = $0.50."
          },
          "coverImageId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "publishedAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time",
            "description": "Last row update (trigger-maintained): a freshness signal for re-fetch decisions."
          },
          "tags": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ArticleTag"
            }
          },
          "creator": {
            "type": "object",
            "description": "The byline: `handle` is the word-handle or 0x address; `displayName` falls back to the handle when unset.",
            "properties": {
              "handle": {
                "type": "string"
              },
              "displayName": {
                "type": "string"
              }
            },
            "required": [
              "handle",
              "displayName"
            ]
          }
        },
        "required": [
          "id",
          "slug",
          "title",
          "excerpt",
          "price",
          "publishedAt",
          "updatedAt",
          "tags",
          "creator"
        ]
      },
      "ArticlesPage": {
        "type": "object",
        "description": "Cursor-paginated page. Pass `nextCursor` back as `?cursor=` for the next page; null means the last page.",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ArticleListItem"
            }
          },
          "nextCursor": {
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "items",
          "nextCursor"
        ]
      },
      "CreatorListItem": {
        "type": "object",
        "description": "A creator-directory row (lib/discovery.ts CreatorListItem). `handle` is the URL identifier (word-handle or 0x address).",
        "properties": {
          "handle": {
            "type": "string"
          },
          "displayName": {
            "type": "string"
          },
          "walletAddress": {
            "type": "string",
            "description": "The 0x wallet address — the permanent identity."
          },
          "avatarImageId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "bio": {
            "type": "string",
            "description": "Always a string — the column is NOT NULL DEFAULT ''."
          },
          "articleCount": {
            "type": "integer",
            "description": "Count of this creator's published posts (discoverable only; unlisted is excluded)."
          }
        },
        "required": [
          "handle",
          "displayName",
          "walletAddress",
          "bio",
          "articleCount"
        ]
      },
      "CreatorsPage": {
        "type": "object",
        "description": "Cursor-paginated page. Pass `nextCursor` back as `?cursor=` for the next page; null means the last page.",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CreatorListItem"
            }
          },
          "nextCursor": {
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "items",
          "nextCursor"
        ]
      },
      "TagListItem": {
        "type": "object",
        "description": "A tag-directory row (lib/discovery.ts TagListItem): the display name, the slug, and the count of visible posts carrying it.",
        "properties": {
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string"
          },
          "articleCount": {
            "type": "integer"
          }
        },
        "required": [
          "name",
          "slug",
          "articleCount"
        ]
      },
      "TagsPage": {
        "type": "object",
        "description": "Cursor-paginated page. Pass `nextCursor` back as `?cursor=` for the next page; null means the last page.",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TagListItem"
            }
          },
          "nextCursor": {
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "items",
          "nextCursor"
        ]
      },
      "CreatorProfile": {
        "type": "object",
        "description": "One creator's public profile + a page of their articles (newest-first, cursor-paginated). Preview-only.",
        "properties": {
          "creator": {
            "type": "object",
            "properties": {
              "handle": {
                "type": "string",
                "description": "The URL identifier (word-handle or 0x address)."
              },
              "displayName": {
                "type": "string"
              },
              "walletAddress": {
                "type": "string"
              },
              "avatarImageId": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "uuid"
              },
              "bio": {
                "type": "string"
              }
            },
            "required": [
              "handle",
              "displayName",
              "walletAddress",
              "bio"
            ]
          },
          "articles": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ArticleListItem"
            }
          },
          "nextCursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Pass back as `?cursor=` for the next page; null on the last page."
          }
        },
        "required": [
          "creator",
          "articles",
          "nextCursor"
        ]
      },
      "ReadArticlePreview": {
        "type": "object",
        "description": "The pre-payment article preview — also the JSON body of the 402 challenge. Public fields only; the paid body (bodyHtmlPaid) is absent until payment settles.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "slug": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "excerpt": {
            "type": "string"
          },
          "bodyHtmlPreview": {
            "type": "string",
            "description": "Rendered free-preview HTML (the portion before the `<!--paywall-->` split)."
          },
          "coverImageId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "price": {
            "type": "string",
            "description": "Atomic USDC units (6 decimals) as a digit string. \"500000\" = $0.50."
          },
          "arbiterId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "status": {
            "type": "string",
            "enum": [
              "published",
              "unlisted"
            ]
          },
          "publishedAt": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601; falls back to createdAt for an unlisted post with no publish date."
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "creator": {
            "type": "object",
            "description": "The byline: `handle` is the word-handle or, when unclaimed, the 0x address; `displayName` falls back to that identifier.",
            "properties": {
              "handle": {
                "type": "string"
              },
              "displayName": {
                "type": "string"
              },
              "walletAddress": {
                "type": "string"
              },
              "avatarImageId": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "uuid"
              }
            },
            "required": [
              "handle",
              "displayName",
              "walletAddress"
            ]
          }
        },
        "required": [
          "id",
          "slug",
          "title",
          "excerpt",
          "bodyHtmlPreview",
          "price",
          "status",
          "publishedAt",
          "tags",
          "creator"
        ]
      },
      "ReadArticleUnlocked": {
        "description": "The unlocked essay: every preview field plus the paid body (bodyHtmlPaid).",
        "allOf": [
          {
            "$ref": "#/components/schemas/ReadArticlePreview"
          },
          {
            "type": "object",
            "properties": {
              "bodyHtmlPaid": {
                "type": "string",
                "description": "Rendered (sanitized) full-essay HTML — present only after payment settles, or in-band for a free post. May be `\"\"` for a teaser-only post."
              }
            },
            "required": [
              "bodyHtmlPaid"
            ]
          }
        ]
      }
    }
  }
}