# Task: build the Scaup content API for this site

You are working in this website's codebase. Build a small REST API that lets Scaup (an SEO service) read and update pages and posts. Follow this spec exactly. Use the site's existing framework, data layer and conventions. Do not add features that this spec does not ask for.

Before you write code:

1. Find where pages and posts are stored, and how they are published.
2. Find where the page `<head>` is rendered (title, meta description).
3. Find any page cache or CDN that must be cleared after a change.
4. Write a short plan that maps each field below to the real field in this codebase. Show it to me before you build.

## 1. General

- Base path: `/api/scaup/v1`. HTTPS only. JSON in and out.
- Auth: header `Authorization: Bearer <key>`. Store only a hash of the key. Compare in constant time. Missing or wrong key returns `401`.
- The key reaches only the endpoints in this file. No access to users, orders, settings or other files.
- Support two valid keys at once, so the key can be rotated.
- Rate limit: 60 requests per minute per key. Over the limit returns `429`.
- Errors: normal HTTP codes with body `{ "error": "<short_code>" }`. Never return stack traces or internal messages.
- Every page and post has a stable `id` that does not change when its URL changes.
- Every write returns the saved record, read back from storage.
- Log each write: endpoint, page `id`, time. Never log the key or the request body.

## 2. Required endpoints

### `GET /pages?page=1&per_page=100`

Lists published pages and posts. `per_page` max 100.

```json
{
  "items": [
    { "id": "abc123", "type": "post", "url": "https://yoursite.com/blog/my-post",
      "title": "...", "status": "published", "updated_at": "2026-11-04T10:00:00Z" }
  ],
  "page": 1,
  "total_pages": 3
}
```

`url` is the full public URL. `type` is `page` or `post`.

### `GET /pages/{id}`

```json
{
  "id": "abc123",
  "type": "post",
  "url": "https://yoursite.com/blog/my-post",
  "status": "published",
  "version": 17,
  "seo_title": "...",
  "meta_description": "...",
  "body_html": "<p>...</p>",
  "updated_at": "2026-11-04T10:00:00Z"
}
```

- `version` is an integer that goes up by one on every save, from any source (admin editor or API).
- `body_html` is the stored body, not the rendered page.
- Unknown `id` returns `404`.

### `PATCH /pages/{id}`

Body: `version` plus any subset of the editable fields.

```json
{ "version": 17, "seo_title": "New title" }
```

- `version` is required. If it does not match the stored version, return `409` and save nothing. Do the check and the save in one transaction.
- Change only the fields in the body. Leave every other field as it is.
- Publish the change, then clear the cache for that page.
- `{ "version": 17, "status": "draft" }` unpublishes the page. There is no delete endpoint.

### `POST /posts`

```json
{
  "title": "...",
  "slug": "my-new-article",
  "body_html": "<h2>...</h2><p>...</p>",
  "seo_title": "...",
  "meta_description": "...",
  "featured_image_url": "https://...",
  "status": "published"
}
```

- `status` is `published` or `draft`.
- A slug that already exists returns `409`.
- Returns `201` with the full record (same shape as `GET /pages/{id}`).

## 3. Recommended

Add these fields to `GET` and `PATCH /pages/{id}`:

- `og_title`, `og_description`: print in `<head>` as `og:title` and `og:description`. When empty, fall back to `seo_title` and `meta_description`. Treat an empty string as empty.
- `schema_jsonld`: print in `<head>` as `<script type="application/ld+json">...</script>` only when not empty.
- `noindex` (boolean): print `<meta name="robots" content="noindex">` when true.

Site files:

- `GET /files/robots.txt`, `PUT /files/robots.txt`, and the same for `llms.txt`.
- Body: `{ "version": 3, "content": "..." }`. Same `409` rule as pages.
- Serve the stored text at `/robots.txt` and `/llms.txt` with `Content-Type: text/plain; charset=utf-8`. Remove any static file that would shadow it.

Sitemap: make sure `/sitemap.xml` lists every published page and post automatically, including posts created by `POST /posts`.

## 4. Security (required)

Validate on the server before saving. On any failure return `400` with `{ "error": "invalid_html" }` (or the matching code) and save nothing. Never try to clean or fix the input.

`body_html`:

- Allowed tags only: `p, a, strong, em, ul, ol, li, br, h2, h3, h4, img`.
- Allowed attributes only: `href` on `a`; `src` and `alt` on `img`.
- `href`: `https://...` or a path that starts with a single `/`. Reject `javascript:`, `data:`, `vbscript:`, `//...` and anything else.
- `src`: `https://...` only.
- Reject `script, style, iframe, object, embed, form, svg, math`, every `on*` attribute, every `style` attribute, and HTML comments.
- Use a real HTML parser for this check. Do not use regular expressions.

Other fields:

- `seo_title`, `meta_description`, `og_title`, `og_description`, `title`: plain text. Reject any `<` or `>`. HTML-encode on output.
- `schema_jsonld`: must parse as JSON. Reject it if it contains `</script` (any case).
- `slug`: lowercase letters, digits and hyphens only.
- `robots.txt`, `llms.txt`: plain text, max 100 KB.
- `featured_image_url`: `https://` only.

## 5. Unit tests

Write unit tests in the site's own test framework for the HTML allowlist and every field check in section 4.

## 6. Acceptance check (required)

Build a standalone check script, `scaup-api-check`, that tests the API from the outside over HTTP. It must not import any code from this site. It is the proof that the API is ready.

Usage: `scaup-api-check --url <base-url> --key <key> --page <test-page-id>`. The test page is a published page on staging that the script may change.

The script runs these checks and prints one line per check: `PASS`, `FAIL` (with the reason) or `SKIP` (only for parts of section 3 that were not built).

Auth and errors:

1. No key returns `401`. A wrong key returns `401`.
2. An unknown page id returns `404` with `{ "error": "..." }` and no stack trace.

Read:

3. `GET /pages` returns `items` with every list field, and an integer `total_pages`.
4. `GET /pages/{id}` returns every field in section 2. `version` is an integer. `url` is a full https URL.

Write (on the test page):

5. `PATCH` with one field changes only that field. `version` goes up by exactly one.
6. `PATCH` with an old `version` returns `409`. The stored value and version do not change.
7. `PATCH` without `version` is rejected.
8. A `body_html` that uses every allowed tag and attribute is stored byte for byte.
9. Each of these `body_html` values returns `400`, and the stored body and version do not change: `<script>`, `<img ... onerror=...>`, `<a href="javascript:...">`, `<a href="//evil.example.com">`, `<a href="data:...">`, `<p style="...">`, `<iframe>`, `<svg>`, `<img src="http://...">`, an HTML comment.
10. `seo_title` with `<b>` returns `400`.
11. If `schema_jsonld` is built: a value with `</script` returns `400`, and invalid JSON returns `400`.
12. After a `PATCH` of `seo_title`, the public page URL shows the new title within 5 minutes. This proves the cache is cleared.
13. At the end, even when a check failed, put back the test page's original `seo_title` and `body_html`, and check that they match.

Posts:

14. `POST /posts` with `status: "published"` returns `201` and the full record.
15. A second `POST` with the same slug returns `409`.
16. The new post URL is in `/sitemap.xml`.
17. `PATCH` with `{ "version": ..., "status": "draft" }` unpublishes it.

Site files (if built):

18. For `robots.txt` and `llms.txt`: `PUT` with an old `version` returns `409`. `PUT` of the same content with the current `version` returns `200`. The public `/robots.txt` and `/llms.txt` return `200`, `Content-Type: text/plain` and the stored content.

The last lines of the output are the counts and either `RESULT: READY` (no `FAIL`) or `RESULT: NOT READY`. The script exits with code 1 when any check fails.

Run it on staging only. Repeat until the result is `RESULT: READY`. Do not weaken a check to make it pass, and do not special-case test values in the API.

## 7. When you are done

Report:

- The full output of `scaup-api-check`.
- Where the check script lives and how to run it, so Scaup can run it too.

- The base URL.
- How to create and rotate a key.
- Any field name that differs from this spec, and why.
- Any part of the spec you could not build, and why.
