{
  "openapi": "3.1.0",
  "info": {
    "title": "lunelet API",
    "version": "1.1.0",
    "description": "Public reading status, optional read-only Goodreads connection, and authenticated revision-checked edits. One owner per deployment. Fetch the shelf and its ETag before every mutation. Never put the write key in a public embed. This is a contract for a user-owned deployment. Lunelet.com does not provide a hosted write API or issue owner keys."
  },
  "servers": [
    {
      "url": "https://your-site.example",
      "description": "Replace with the reader’s own authorised API deployment. Not lunelet.com or the demonstration."
    }
  ],
  "paths": {
    "/api/reading": {
      "get": {
        "operationId": "getReadingShelf",
        "summary": "Read current books and finished history",
        "responses": {
          "200": {
            "description": "The public shelf. Its ETag is the quoted revision (for example, \"3\").",
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Shelf"
                }
              }
            }
          },
          "304": {
            "description": "Unchanged when If-None-Match matched the revision."
          }
        }
      },
      "patch": {
        "operationId": "updateReadingShelf",
        "summary": "Start, finish, edit or undo a book change",
        "security": [
          {
            "ownerKey": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "If-Match",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Use the ETag from the most recent GET, including quotes."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Action"
              },
              "examples": {
                "start": {
                  "value": {
                    "action": "start",
                    "book": {
                      "title": "A book title",
                      "author": "An author",
                      "workId": "OL123W"
                    }
                  }
                },
                "finishAndNext": {
                  "value": {
                    "action": "start",
                    "finishId": "CURRENT_BOOK_ID",
                    "book": {
                      "title": "The next book",
                      "author": "An author"
                    }
                  }
                },
                "finish": {
                  "value": {
                    "action": "finish",
                    "id": "BOOK_ID",
                    "date": "2026-09-13"
                  }
                },
                "progress": {
                  "value": {
                    "action": "update",
                    "id": "BOOK_ID",
                    "changes": {
                      "progress": 45,
                      "note": "A short thought."
                    }
                  }
                },
                "history": {
                  "value": {
                    "action": "add-finished",
                    "date": "2026-08-31",
                    "book": {
                      "title": "An earlier book",
                      "author": "An author"
                    }
                  }
                },
                "resume": {
                  "value": {
                    "action": "resume",
                    "id": "BOOK_ID"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Saved. All changes in the action are atomic; returns the new shelf and ETag.",
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Shelf"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Invalid"
          },
          "401": {
            "description": "Missing or invalid owner key."
          },
          "403": {
            "description": "Cookie-authenticated writes require the same website origin."
          },
          "404": {
            "description": "Book no longer exists."
          },
          "409": {
            "description": "Duplicate current book, incompatible status, or Goodreads is connected. Disconnect before manual editing."
          },
          "412": {
            "description": "Revision changed. Read the latest shelf and ask/reconcile before retrying. Never blindly overwrite."
          },
          "413": {
            "description": "Request exceeds 512,000 bytes."
          },
          "415": {
            "description": "Content-Type must be application/json."
          },
          "428": {
            "description": "If-Match is required."
          },
          "503": {
            "description": "Live editing is unconfigured or temporarily unavailable. No successful save is claimed."
          }
        }
      }
    },
    "/api/reading/goodreads/feed": {
      "get": {
        "operationId": "getPublicGoodreadsShelf",
        "summary": "Read a public Goodreads profile as an embeddable shelf; does not change the owner's shelf",
        "description": "One-way RSS adapter. No authentication or Goodreads key. At most 24 current books and 100 recent finished books. Cached for an hour, plus five minutes of HTTP response caching. Reviews and ratings are excluded. Unknown dates are null. Not a complete-library export or a real-time guarantee.",
        "parameters": [
          {
            "in": "query",
            "name": "user",
            "required": true,
            "schema": {
              "type": "string",
              "maxLength": 500
            },
            "description": "Numeric Goodreads user ID or HTTPS /user/show/ profile URL. Not an author ID, book ID, arbitrary URL, or private RSS link."
          }
        ],
        "responses": {
          "200": {
            "description": "The public Goodreads shelf, with CORS and ETag.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Shelf"
                }
              }
            }
          },
          "304": {
            "description": "Unchanged according to If-None-Match."
          },
          "400": {
            "$ref": "#/components/responses/Invalid"
          },
          "502": {
            "description": "Private, unavailable, oversized or invalid upstream feed. Never treat this as an empty shelf."
          }
        }
      }
    },
    "/api/reading/goodreads": {
      "get": {
        "operationId": "getGoodreadsConnection",
        "summary": "Read the owner's Goodreads connection status",
        "security": [
          {
            "ownerKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "Connection status and current shelf ETag.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GoodreadsConnection"
                }
              }
            }
          },
          "401": {
            "description": "Owner authentication required."
          }
        }
      },
      "post": {
        "operationId": "manageGoodreadsConnection",
        "summary": "Preview, connect or refresh a Goodreads profile",
        "description": "Preview is read-only. Connect switches the displayed source after user confirmation and preserves the manual shelf for restoration. Sync checks the connected profile now. Manual book PATCH is blocked while connected. These operations never write to Goodreads. Cookie-authenticated calls require same Origin.",
        "security": [
          {
            "ownerKey": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "If-Match",
            "schema": {
              "type": "string"
            },
            "description": "Required for connect and sync; quoted revision from latest GET /api/reading. Optional for preview."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "type": "object",
                    "required": [
                      "action",
                      "profile"
                    ],
                    "properties": {
                      "action": {
                        "enum": [
                          "preview",
                          "connect"
                        ]
                      },
                      "profile": {
                        "type": "string",
                        "maxLength": 500
                      }
                    }
                  },
                  {
                    "type": "object",
                    "required": [
                      "action"
                    ],
                    "properties": {
                      "action": {
                        "const": "sync"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Preview returns a Shelf. Connect/sync return the shelf and connection. A failed sync retains last-good data and reports connection.lastError, without claiming a fresh snapshot.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/Shelf"
                    },
                    {
                      "$ref": "#/components/schemas/GoodreadsResult"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Invalid"
          },
          "401": {
            "description": "Owner authentication required."
          },
          "403": {
            "description": "Cookie authentication requires same Origin."
          },
          "409": {
            "description": "Disconnect the existing profile first, or connect before syncing."
          },
          "412": {
            "description": "Shelf changed. Re-read and reconcile before retrying."
          },
          "428": {
            "description": "If-Match is required for connection changes."
          },
          "502": {
            "description": "Goodreads preview/connect unavailable. The existing shelf is unchanged."
          },
          "503": {
            "description": "Owner editing is unconfigured or storage unavailable."
          }
        }
      },
      "delete": {
        "operationId": "disconnectGoodreads",
        "summary": "Disconnect Goodreads and restore the original manual shelf",
        "description": "Restores manual books verbatim. Does not merge Goodreads books into them or change Goodreads. Cookie-authenticated calls require same Origin.",
        "security": [
          {
            "ownerKey": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "If-Match",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Manual shelf restored.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GoodreadsResult"
                }
              }
            }
          },
          "401": {
            "description": "Owner authentication required."
          },
          "403": {
            "description": "Cookie authentication requires same Origin."
          },
          "409": {
            "description": "No connection exists."
          },
          "412": {
            "description": "Shelf changed. Read again before retrying."
          },
          "428": {
            "description": "If-Match is required."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ownerKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "The private READING_TOKEN. Keep it outside browser code and URLs."
      }
    },
    "responses": {
      "Invalid": {
        "description": "Validation error.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "error"
              ],
              "properties": {
                "error": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "schemas": {
      "BookInput": {
        "type": "object",
        "required": [
          "title",
          "author"
        ],
        "properties": {
          "title": {
            "type": "string",
            "minLength": 1,
            "maxLength": 240
          },
          "author": {
            "type": "string",
            "minLength": 1,
            "maxLength": 240
          },
          "workId": {
            "type": "string",
            "pattern": "^(OL[0-9]+W)?$",
            "description": "Open Library work ID (not an edition ID). Empty when unknown."
          },
          "cover": {
            "type": "string",
            "maxLength": 2048,
            "description": "HTTPS URL or root-relative path. Empty when unavailable."
          },
          "url": {
            "type": "string",
            "maxLength": 2048,
            "description": "HTTPS book link or root-relative path."
          },
          "startedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date",
            "description": "Null if the actual reading start date is unknown."
          },
          "finishedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "progress": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 0,
            "maximum": 100
          },
          "note": {
            "type": "string",
            "maxLength": 280
          }
        }
      },
      "Book": {
        "allOf": [
          {
            "$ref": "#/components/schemas/BookInput"
          },
          {
            "type": "object",
            "required": [
              "id",
              "status"
            ],
            "properties": {
              "id": {
                "type": "string",
                "maxLength": 80
              },
              "status": {
                "enum": [
                  "reading",
                  "finished"
                ]
              }
            }
          }
        ]
      },
      "Shelf": {
        "type": "object",
        "required": [
          "schemaVersion",
          "revision",
          "name",
          "updatedAt",
          "books"
        ],
        "properties": {
          "schemaVersion": {
            "const": 1
          },
          "revision": {
            "type": "integer",
            "minimum": 0
          },
          "name": {
            "type": "string"
          },
          "updatedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "books": {
            "type": "array",
            "maxItems": 2000,
            "items": {
              "$ref": "#/components/schemas/Book"
            }
          },
          "source": {
            "type": "object",
            "description": "Present for Goodreads-backed feeds.",
            "properties": {
              "type": {
                "const": "goodreads"
              },
              "userId": {
                "type": "string"
              },
              "profileUrl": {
                "type": "string",
                "format": "uri"
              },
              "lastSyncedAt": {
                "type": "string",
                "format": "date-time"
              },
              "stale": {
                "type": "boolean"
              },
              "currentLimit": {
                "const": 24
              },
              "historyLimit": {
                "const": 100
              },
              "currentTruncated": {
                "type": "boolean"
              },
              "historyMayBeTruncated": {
                "type": "boolean"
              }
            }
          }
        }
      },
      "GoodreadsConnection": {
        "type": "object",
        "required": [
          "connected"
        ],
        "properties": {
          "connected": {
            "type": "boolean"
          },
          "userId": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "profileUrl": {
            "type": "string",
            "format": "uri"
          },
          "lastSyncedAt": {
            "type": "string",
            "format": "date-time"
          },
          "nextCheckAt": {
            "type": "string",
            "format": "date-time"
          },
          "lastError": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "GoodreadsResult": {
        "type": "object",
        "required": [
          "shelf",
          "connection"
        ],
        "properties": {
          "shelf": {
            "$ref": "#/components/schemas/Shelf"
          },
          "connection": {
            "$ref": "#/components/schemas/GoodreadsConnection"
          }
        }
      },
      "Action": {
        "oneOf": [
          {
            "type": "object",
            "required": [
              "action",
              "book"
            ],
            "properties": {
              "action": {
                "const": "start"
              },
              "book": {
                "$ref": "#/components/schemas/BookInput"
              },
              "finishId": {
                "type": "string",
                "description": "Optionally finish this existing current book in the same atomic save."
              },
              "date": {
                "type": "string",
                "format": "date",
                "description": "Finish date of finishId; defaults to today. New book start date belongs in book.startedAt."
              }
            }
          },
          {
            "type": "object",
            "required": [
              "action",
              "book",
              "date"
            ],
            "properties": {
              "action": {
                "const": "add-finished"
              },
              "book": {
                "$ref": "#/components/schemas/BookInput"
              },
              "date": {
                "type": "string",
                "format": "date"
              }
            }
          },
          {
            "type": "object",
            "required": [
              "action",
              "id"
            ],
            "properties": {
              "action": {
                "enum": [
                  "finish",
                  "resume",
                  "remove"
                ]
              },
              "id": {
                "type": "string"
              },
              "date": {
                "type": "string",
                "format": "date"
              }
            }
          },
          {
            "type": "object",
            "required": [
              "action",
              "id",
              "changes"
            ],
            "properties": {
              "action": {
                "const": "update"
              },
              "id": {
                "type": "string"
              },
              "changes": {
                "type": "object",
                "description": "Any editable BookInput fields. Status and ID are preserved. Only send fields being changed."
              }
            }
          },
          {
            "type": "object",
            "required": [
              "action",
              "books"
            ],
            "properties": {
              "action": {
                "const": "restore"
              },
              "books": {
                "type": "array",
                "maxItems": 2000,
                "items": {
                  "$ref": "#/components/schemas/Book"
                },
                "description": "Previous books array, for undo/import. Must use the revision immediately preceding this restore."
              }
            }
          }
        ]
      }
    }
  }
}
