# Public site The contract for public pages: what the PWA uploads to scrap, what scrap pushes to the public site's receiver, and how nginx serves it. This page is the single source of truth for it; the `scrap-public` image and scrap both follow it. Why it is built this way is in [](../explanations/decisions/0043-public-pages-a-static-site-scrap-pushes-to.md). scrap is the receiver's only client. The PWA never talks to the receiver, and the receiver is never on a public ingress: only nginx is. ## Settings The `scrap-public` image has two commands, `web` (nginx, port 8080) and `receive` (the receiver). In Kubernetes they are two containers of one pod, sharing the site volume at `/site`. | Variable | Read by | Default | Meaning | | --- | --- | --- | --- | | `RECEIVER_LISTEN` | `receive` | `tcp:0.0.0.0:8081` | Where it listens, `tcp::` (a Unix socket may be added for the later single-server install) | | `RECEIVER_TOKEN_FILE` | `receive` | unset | A file holding the token (its content, stripped). Wins over `RECEIVER_TOKEN`. This is how the receiver gets it in a deployment: the chart mounts the Secret as a file, never as an environment variable | | `RECEIVER_TOKEN` | `receive` | unset | The token itself, for tests and development only | | `RECEIVER_SITE` | `receive` | `/site` | The site folder ([layout](#the-site-folder)) | | `RECEIVER_QUOTA_BYTES` | `receive` | 2 GiB | The site's quota, in bytes | | `INDEXING` | `web` | `off` | `on` lets search engines index the site; `off` sends `X-Robots-Tag: noindex, nofollow` and a `robots.txt` of `Disallow: /`. Anything else stops `web` at start | The receiver does not start without a token, with one shorter than 32 characters, or with one holding whitespace. scrap: | Variable | Meaning | | --- | --- | | `SCRAP_PUBLISH_TO` | The receiver's base URL, e.g. `http://scrap-receiver.scrap-public.svc:8081` (the chart's Service is `-receiver`, here for a release named `scrap`) | | `SCRAP_PUBLISH_SITE` | The site's public base URL, e.g. `https://pages.gkcluster.org`, for the links scrap shows | | `SCRAP_PUBLISH_TOKEN_FILE` | A file holding the same token (its content, stripped). scrap reads the token only from a file, never from an environment variable | Without `SCRAP_PUBLISH_TO` and a token, publishing is off. A token file that is set but unreadable, shorter than 32 characters or holding whitespace, or a `SCRAP_PUBLISH_TO` that is not an `http(s)` URL, stops scrap starting. ## Transport and auth - HTTP/1.1 with keep-alive. Receiving a request, its headers and body, has 30 seconds (`408`); the fsync and swap after it are not counted. - A client should send `Expect: 100-continue` with every request that has a body, where the client library supports it, so a refusal that needs only the headers (`401`, `411`, `413`, `415`, `422 bad_unit`, and `503 busy`, since the write lock is tried from the headers before `100 Continue`) arrives before the body is sent. When the receiver refuses a body it has not read, it closes the connection lingering (it reads and drops what is still arriving for a moment first), so a client that cannot send `Expect` still sees the answer rather than a reset. - Every route except `GET /healthz` needs `Authorization: Bearer `, compared with `hmac.compare_digest`. Without it the answer is `401` with `WWW-Authenticate: Bearer`, before any other check: an unknown path is `401` too, not `404`. The token, and the `Authorization` header, are never logged; the log has the request line and status only. - Every error is JSON, `{"error": "", "detail": ""}` ([](#errors)). - A wrong method on a known route is `405` with `Allow`. ## Units The site is made of **units**. A unit is replaced whole or deleted whole; nothing writes one file inside a unit. | Unit | Pattern | Must hold | May hold | Served at | | --- | --- | --- | --- | --- | | A page | `p/[a-z0-9-]{1,120}` | `index.html` | its images and files (below) | `/p/`, `/p//`, `/p//` | | A tag | `t/[a-z0-9-]{1,80}` | `index.html` | nothing else | `/t/`, `/t//` | | The home page | `home` | `index.html` | any other one-segment `.html` (scrap sends `tags.html` and `404.html`) | `/`, `/tags`, every 404 | | The search index | `pagefind` | at least one file | Pagefind's files and `scrap-search.js` | `/pagefind/` | | Shared assets | `assets` | at least one file | the site's CSS, KaTeX's fonts, `prefs.js` and the site's icons | `/assets/` | The unit is the raw request path after `/v1/units/`, matched exactly against the pattern (no decoding). Anything else, such as `%`, a `.` segment, `//`, a trailing `/`, upper case or a query string, is `422 bad_unit`. What each kind may hold. A member that breaks its kind's rule is `422 bad_member`: | Unit | Members | | --- | --- | | `p/*` | `index.html`; `img--.jpg`, `.png` or `.svg`; `file--.` with `` one of `pdf`, `txt`, `md`, `csv`, `zip`, `epub`, `docx`, `xlsx`, `pptx`, `odt`, `ods`, `odp`, `bin`. `` is `[1-9][0-9]?`, `` is `[0-9a-f]{10}`. One segment each: no folders | | `t/*` | `index.html` only | | `home` | `.html` files, one segment each (`robots.txt` is not in a unit: nginx makes it) | | `pagefind` | `js`, `mjs`, `json`, `wasm`, `pagefind`, `pf_meta`, `pf_index`, `pf_fragment`, `pf_filter` | | `assets` | `css`, `woff`, `woff2`, `ttf`; and `prefs.js`, `icon.svg` and `icon-192.png`, at its top only | So `.wasm` and every script but one exist only under `/pagefind/`; the one other script is `/assets/prefs.js`, the reader's theme and column width ([](#reader-preferences)). CSS and fonts exist only under `/assets/`, and no page or tag unit can hold a script or a stylesheet. The site's icons, scrap's own (#527), are the only other files in `assets`, and the only SVG there. An attachment is never published as `html`, `htm`, `js`, `mjs`, `css` or `svg`: an SVG attachment is published as an image (`img-*.svg`), and a type outside the list as `bin`. ## The tar A unit's body is a tar: - **uncompressed**, in pax or ustar format; - of **regular files only** (`REGTYPE`): a directory entry, symlink, hard link, device or FIFO is refused (`422 bad_member`); a GNU tar member type or a sparse member is refused as `400 bad_tar`; - with **relative paths and no `./` prefix**: 1 to 4 segments joined by `/`, each matching `[A-Za-z0-9_][A-Za-z0-9._-]{0,99}` (so no leading `.`, no `..`, no absolute path), within the unit's own rule above; - each path **once**, and no path that is also a folder of another (`a` and `a/b.html`); - **not empty** (`422 empty_unit`); - with no pax extended header, or GNU long-name or long-link header, over 64 KiB (`400 bad_tar`). The extension is what follows the last `.` of the last segment, compared in lower case. The receiver reads the tar member by member as it streams in, writing each file itself; it never uses `extractall`. Any refusal leaves the site as it was. ## Limits | Limit | Value | Over it | | --- | --- | --- | | Files in a unit | 10,000 | `413 too_many_files` | | One file | 20 MiB | `413 file_too_large` | | The request body | 256 MiB | `413 body_too_large`, from `Content-Length`, before the body is read | | The site | `RECEIVER_QUOTA_BYTES`, 2 GiB | `507 over_quota`, counted over every live unit, the one being replaced at its new size; each file counts as its size rounded up to whole 4 KiB blocks, at least 4096 bytes | | Receiving a request (headers and body) | 30 s | `408 timeout` | scrap's own limits are tighter for a page: at most 20 images and files, each at most 20 MiB, and at most 200 MiB for the unit ([](#what-scrap-sends-for-a-page)). ## The digest Every `PUT` carries `Scrap-Digest: <64 lower-case hex>`: the SHA-256 of, for each file in sorted path order (byte order; paths are ASCII), the line ``` NUL LF ``` concatenated. In Python: ```python hashlib.sha256(b"".join( p.encode() + b"\0" + hashlib.sha256(files[p]).hexdigest().encode() + b"\n" for p in sorted(files) )).hexdigest() ``` The receiver computes it again from the tar. A missing or different digest is `400 digest_mismatch`, and nothing changes. It is also the unit's `digest` in the manifest. ## Endpoints ### `GET /healthz` No token. Always `200 {"ok": true}` while the receiver runs. ### `GET /v1/manifest` `200` with what the site holds: ```json { "version": 1, "bytes": 12345678, "quota": 2147483648, "units": { "p/my-whiteboard": {"digest": "", "files": 3, "bytes": 812345, "at": "2026-10-31T14:02:11Z"}, "home": {"digest": "", "files": 3, "bytes": 20480, "at": "2026-10-31T14:02:12Z"} } } ``` `bytes` (the site's and each unit's) is the files' actual content in bytes; only the quota counts in 4 KiB blocks ([](#limits)). `quota` is the limit, and `at` when the unit was last written (UTC, ISO 8601, to the second). ### `PUT /v1/units/{unit}` Replace a unit, or make it. Required: `Content-Type: application/x-tar` (`415 unsupported_media_type`), `Content-Length` (`411 length_required`; a chunked body is `411` too, and a length that is not a number `400 bad_request`) and `Scrap-Digest`. Send `Expect: 100-continue` where the client supports it ([](#transport-and-auth)). One lock serialises every write (`PUT`, `DELETE`, `sync`). It is tried as soon as the headers are in, before `100 Continue`; a request that cannot take it at once gets `503 busy` with `Retry-After: 1`, before any body is sent. The swap is atomic, so nginx serves the old unit or the new one, never a mix or nothing: the files are unpacked into `staging//` and fsynced, moved to `units//` with the unit's manifest entry beside it, then a new symlink at `www/` replaces the old one with `os.replace`, and the old copy is deleted. `200` with the unit's manifest entry and `"unit"`. Other answers: `400 bad_tar`, `400 short_body` (fewer bytes than `Content-Length`), `400 digest_mismatch`, `422 bad_member`, `422 no_index`, `422 empty_unit`, `413`, `507`, `408`. ### `DELETE /v1/units/{unit}` Remove a unit. `204`, also when it was not there, or `503 busy`. ### `POST /v1/sync` `{"keep": ["home", "pagefind", "assets", "p/a", "t/b", ...]}` deletes every live unit not in the list. It needs `Content-Type: application/json` (`415`) and `Content-Length` (`411`), with a body of at most 1 MiB. `200 {"deleted": [...]}`. Refused: a body that is not `{"keep": [strings]}` (`400 bad_request`), an empty list (`422 empty_keep`), a list without `home` (`422 no_home`), and an entry that is not a unit (`422 bad_unit`). ## Errors | Status | `error` | When | | --- | --- | --- | | `400` | `bad_request` | A malformed `Content-Length`, or a `sync` body that is not `{"keep": [...]}` | | `400` | `short_body` | The body ended before `Content-Length` | | `400` | `bad_tar` | The body is not a readable tar, has a GNU or sparse member, or a header over 64 KiB | | `400` | `digest_mismatch` | `Scrap-Digest` missing or wrong | | `401` | `unauthorized` | No token, or the wrong one | | `404` | `not_found` | No such route | | `405` | `method_not_allowed` | A method the route does not take, or an unknown method (with `Allow`) | | `408` | `timeout` | Receiving the request took over 30 s, or stalled | | `411` | `length_required` | No `Content-Length`, or a chunked body | | `413` | `body_too_large`, `too_many_files`, `file_too_large` | Over a limit | | `414` | `uri_too_long` | The request line is too long | | `415` | `unsupported_media_type` | The wrong `Content-Type` | | `422` | `bad_unit` | Not a unit | | `422` | `bad_member` | A member's type, path or extension is not allowed, it is there twice, or it is a folder too | | `422` | `no_index` | A page, tag or home unit without `index.html` | | `422` | `empty_unit` | A tar with no files | | `422` | `empty_keep`, `no_home` | A `sync` list that is refused | | `431` | `headers_too_large` | The headers are too large | | `500` | `server_error` | Anything unexpected in the receiver | | `503` | `busy` | Another write holds the lock (with `Retry-After: 1`) | | `505` | `version_not_supported` | Not HTTP/1.x | | `507` | `over_quota` | The site would be over its quota, or the disk itself is full | `detail` never repeats the token or a header. ## The site folder `RECEIVER_SITE` holds: - `www/`: nginx's root, holding only each unit's symlink (`www/p/`, `www/t/`, `www/home`, `www/pagefind`, `www/assets`) to its copy; - `units//` and `units/.json`: each unit's files and its manifest entry; - `staging/`: uploads being unpacked. `units/` and `staging/` are outside nginx's root, so they are never served. At start the receiver empties `staging/`, rebuilds its index of live units from the symlinks, and removes every copy no symlink refers to and anything else in `www/`. In Kubernetes the folder is an `emptyDir`: a restarted pod starts empty and scrap fills it again ([](#reconciling)). ## nginx nginx serves `www/` read only, as files. It takes `GET` and `HEAD` only (`405` otherwise), with `autoindex off` and `server_tokens off`. | Path | Serves | | --- | --- | | `/` | `home/index.html` | | `/tags` | `home/tags.html` | | `/robots.txt` | from nginx's own config: `User-agent: *` and `Disallow: /` (`Allow: /` with `INDEXING=on`) | | `/p/`, `/p//` | `p//index.html` | | `/p//` | `p//` | | `/t/`, `/t//` | `t//index.html` | | `/pagefind/` | `pagefind/` | | `/assets/` | `assets/` | | anything else | `home/404.html`, with `404` | `` and `` follow the tar's segment rule. Only `/`, `/tags` and `/robots.txt` are served at the top level: `/home/...`, `/units/...`, `/v1/...` and dotfiles are `404`. Types come from the extension: `.md` as `text/markdown`, `.txt` as `text/plain`, `.ttf` as `font/ttf`, `.wasm` as `application/wasm`, Pagefind's index files and `.bin` as `application/octet-stream`; text types and JSON are `charset=utf-8`. An SVG is exactly `image/svg+xml`, with no `charset`: it is XML, UTF-8 unless it says otherwise, and excalidraw.com opens a drawing's SVG only if its type is exactly that (#526: its `loadFromBlob` compares the Blob's type whole, so `image/svg+xml;charset=utf-8` is read as JSON and refused). `/favicon.ico`, which a browser asks for on its own where nothing names an icon (an image or a PDF opened alone), is a `302` to `/assets/icon.svg`, with a relative `Location` so it holds behind the TLS proxy (#527). ### Headers On every response: - `X-Content-Type-Options: nosniff` - `Referrer-Policy: no-referrer` - `X-Robots-Tag: noindex, nofollow` (left out with `INDEXING=on`) `Content-Security-Policy`, by path: | Path | `Content-Security-Policy` | | --- | --- | | `/p//file-*.pdf` | `sandbox` | | `/p//img-*`, `/p//file-*` (not a PDF), and `/assets/*.svg` (the site's icon) | `sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:` | | `/p/...` and `/t/...` otherwise (a page's or tag's HTML) | `default-src 'none'; img-src 'self' data:; style-src 'self'; style-src-attr 'unsafe-inline'; font-src 'self'; script-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'` | | everything else (home, `/pagefind/`, `/assets/`) | `default-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'` | | every `404`, whatever the path: `home/404.html`, whose only script is `prefs.js` | home's, as the row above | A page's images and files follow ADR 42's rule for stored files: opened on their own they run nothing and load nothing, and a PDF keeps `sandbox` alone so a browser's PDF viewer works. Pages and tag pages run only the site's own scripts (`script-src 'self'`), and the only one they name is `/assets/prefs.js` ([](#reader-preferences)): scrap's check refuses any other script, and the receiver any other `.js` outside `pagefind`. There is no inline script anywhere. The CSP does not also pin `prefs.js` by a hash: a hash source allows an external script only with an `integrity` attribute, and only in some browsers, and `'self'` would allow it anyway; the hash would also tie the image's nginx configuration to each scrap build. The home page's other scripts are Pagefind's and `/pagefind/scrap-search.js` (scrap's start-up file), which fetch the index (`connect-src 'self'`) and run WebAssembly (`'wasm-unsafe-eval'`). A page's SVG images, `/p//img-*.svg`, also get `Access-Control-Allow-Origin: https://excalidraw.com`, a fixed value (the pages are public), so excalidraw.com can fetch a drawing that a reader opens there ([](#drawings)). No other answer has the header. ### Caching - HTML: `Cache-Control: no-cache`, so a browser or proxy checks again before each use, and an unpublished page is gone on its next load. A `304` for a page says `no-cache` too (nginx knows HTML there by its file, `.html`, as a `304` has no `Content-Type`). - The site's own CSS and script, `/assets/*.css` and `/assets/*.js` (`page.css`, `katex.css`, `prefs.js`): `Cache-Control: no-cache` too. Every page names them at the same addresses, so a browser asks again on each load, with the `ETag` nginx sends, and gets a short `304` when nothing changed; a page republished by a new release never shows with the last release's stylesheet (#534). It is `no-cache`, not `no-store`, so browsers and Cloudflare still keep a copy to revalidate. - Behind Cloudflare, the zone's Browser Cache TTL rewrites that `no-cache` to `max-age=14400` unless a Cache Rule for the site's host sets **Browser TTL: Respect origin TTL**. Check with `curl -sI https:///assets/page.css`: it shows `no-cache`. - Every other file: `Cache-Control: public, max-age=300`, so a deleted file can be seen from a cache for up to five minutes more. KaTeX's fonts and the site's icons are among these: they change only with KaTeX or the icon, and a page shown with an older font still reads. Nothing the site does reaches copies people saved, or caches that ignore these headers. ## What the publish mode writes `pagehtml.ts` has a publish mode beside Share as HTML. It reads the page as the PWA shows it and writes one HTML file, which scrap then checks ([](#what-scrap-sends-for-a-page)). The design of the PWA's side (the toggle, the badge, the dialogs) is on the sim's design branch, `design/497-public-pages`. Marked *default* below: a choice the owner may still change, kept in one place so it is easy to change. **Included** - The body as shown, Claude's write-up and Claude's summary. - The tags, as chips: ``, which scrap turns into `/t/` ([](#tag-keys)). - **Linked from** and **Related** (the links scrap adds itself): an entry is kept only if the other page is public. Otherwise the whole entry is left out of those lists (a private page's title can still show in the page's own text, as a link's words: [](#private-titles)). - Maths, as `katex.renderToString` writes it ([](#katexs-markup)). - Mermaid diagrams drawn, as the app draws them, light and dark ([](#diagrams)); one the PWA cannot draw as its source, in `
`.
- Heading and footnote `id`s, so `#...` links within the page work.
- A task's box (#539), as an empty `` in place of the app's ``: in the
  `
  • `, ``, or `` when ticked. `page.css` draws it as the app's box (Chrome's checkbox with the amber accent: its size, edge, corners and tick, light and dark); a screen reader says "To do" or "Done" before the task, and the site's search does not index the label. The Markdown's `[ ]` and `[x]` do not appear. **Links** - To a public page: ``, which works on the site as it does in scrap. - To a private page: a plain `` holding the label the owner typed. With no typed label (a bare `[[slug]]`, or a bare `/p/` address) the text is that page's title, as plain text: a ``, not a link. (The owner's decision; the warning on making a page public says so.) - A bare `[[slug]]` to a public page shows the linked page's title; one to no page (or a page in the trash) is the plain text "a private page". - Elsewhere: `http:` and `https:` links as written, within [the link rules](#links-scrap-accepts). - **No private address in text.** Text (inline code included) that holds an address of scrap's own (one of its hosts, with or without a scheme, port or path), or any `/api/...` path, says "a private link" in its place. In a code block, a Mermaid diagram's source and a diagram's words (#529), only scrap's own addresses (its hosts, and `/api/blobs/...`) are replaced, and a closing `]` after one stays: any other `/api/...` path is the code's own and stays as written. scrap's check refuses text that still holds one ([](#the-html-scrap-accepts)). (private-titles)= **Private titles on public pages.** A public page that links to a private page shows that page's title as words. When a page is made private, or deleted, in the PWA, the PWA draws and publishes again the published pages that refer to it, so its link becomes words, or its title goes. When that happens elsewhere (over MCP, by a revert, or a page trashed from another device), the old title or link stays on the other public pages until Refresh all, or until each of them is next saved in the PWA. There is no stale tracking. (public-pages-and-mcp)= **Public pages and MCP.** MCP never publishes HTML ([ADR 43](../explanations/decisions/0043-public-pages-a-static-site-scrap-pushes-to.md)). A text change to a public page over MCP (`edit_page`, `write` with `append_to`, `add_svg`, `edit_drawing`, `revert`) reaches the public site only when the owner's app next renders the page: a save in the PWA once the page has been published, or Settings > Refresh all public pages. The tool descriptions say so, so a client tells the owner to refresh, and the result of `edit_page`, `add_svg`, `write` with `append_to` and a text `revert` carries `public: true` and a `public_note` for a public page whose change took effect (the note says if the page was never published, when only Refresh all sends it). That is a reminder, not stale tracking: scrap does not know whether the public copy is older. **Images and files** - An image: `...`. The `alt` is published as the app has it, a file name the PWA filled in included (the owner's decision). An image Claude made (`add_svg`, or Claude's `/mcp` upload) has Claude's label, the alt it gave, instead. - A file: `` with the link's label. - **A photo's drawing** (Excalidraw, #520): the photo and the drawing, with Photo | Drawing tabs over them, then its links ([](#drawings)). A drawing the PWA cannot draw fails the publish, rather than going out without it. (claudes-reading)= **Claude's reading** (#521): under each photo or image, and each PDF, that has one, the app's fold, published **closed** (the one `
    ` without `open`; every other fold is published open): ```html
    Claude's reading

    ...the photo's description...

    Text read
    ...the text read
    from it...
    ``` A photo's read text is a fold of its own inside it (#538), published closed too, and still indexed by Pagefind; a PDF's text is a plain `
    ` in the fold, as before.
    
    It follows the photo (and its drawing's links), or the file's row. Its text is scrubbed as page
    text is (no address of scrap's own, no `/api/`: "a private link"), scrap's check holds it to the
    same rules, and Pagefind indexes it, so the site's search finds what is written on a whiteboard.
    A PDF's text is what the app's fold shows: its first 20,000 characters. There is no fold for an
    image Claude made (`add_svg`: Claude doesn't read its own images), for one not read yet, or for
    a text file (its own link opens its text). Make public's warning lists "Claude's reading of its
    photos and files" when the page has any.
    
    **Left out**: Claude's notes on a photo outside its fold (where its reading is up to, Generate
    and Regenerate), revisions, the PWA's controls, and every `/api` or other private address.
    **Never published**: the owner's **Guidance for Claude**, the page's (for its write-up) and each
    photo's (for its reading, #538): the publish mode never writes it, nor does Share as HTML.
    
    **Head**: the fixed meta CSP, `/assets/page.css`, `/assets/katex.css` for a page with maths, the
    site's icons (#527), `` and
    ``, and
    ``
    ([](#the-html-scrap-accepts)).
    
    **The PWA's dialogs** (the sim's wording on the design branch is the final text):
    
    - Making a page public asks every time, Cancel or Make public, saying that anyone with the
      address can read the page, and listing what goes public with it: its tags, Claude's
      write-up and summary, its attached files by name, "its N drawings, downloadable" (only when
      its photos have drawings), and "Links to private pages show their titles as plain text."
    - On a topic page, the warning also lists "Claude's synthesis, which draws on its source pages".
    - A save of a public page while offline is saved but not published, with the toast "Saved. Not
      published while offline: Refresh all public pages in Settings later." Publishes are never
      queued.
    - **Make private** acts at once, with Undo. Its toast says "Private: taken off ", or
      "Private: comes off  shortly" when the answer carries `"unpublish": "pending"`.
    - **A first publish needs the owner's yes.** A public page never published (made public over
      MCP, by a revert or an undelete) is published only from Make public's warning or Refresh
      all's question below, which send `"confirmed": true`. A save, a tick or a link coming alive
      passes it by, and scrap refuses such a publish without `confirmed` (`409 unconfirmed`).
    - **Refresh all** asks first when any public page has never been published (made public over
      MCP, say, or restored): it lists them and asks once, "These pages will go public for the
      first time: … OK / Cancel". Pages already published are refreshed without asking. *Default.*
    
    ## The PWA's API in scrap
    
    These routes are under `/api`, so only the owner's PWA session reaches them (behind Cloudflare
    Access or Tailscale, as the rest of `/api`). They are **not** served under `/mcp`, and an MCP
    bearer token cannot reach them: MCP never publishes HTML. Errors are scrap's usual
    `{"error", "detail"}` JSON. `{key}` is a page's id or slug, as elsewhere in the API.
    
    ### The flag
    
    `PATCH /api/pages/{key}` with `{"public": true}` or `{"public": false}`, or MCP
    `edit_meta(public=...)`, sets `page.public`, as a revision that `revert` can undo. Making pages
    private works in bulk through the same routes; `public: true` that matches more than one page
    (by list or by query) is refused with `422 one_at_a_time`; a query that matches exactly one page
    is allowed. (A revert of a bulk "make private" batch puts every page's flag back; each still
    waits for a confirmed first publish.) Nothing after the flag change has committed answers
    `500`: an error then is logged, and the answer is `200` with `"unpublish": "pending"`.
    
    - **On** only sets the flag. The page is published when the owner confirms it in the PWA: Make
      public (which sets the flag, asks, then publishes) or Refresh all's question. Until then it
      shows "Public · not published yet"; a save does not publish it.
    - **Off** unpublishes at once, whichever route turned it off, and so does the page going to the
      trash. The unpublish runs after the flag change (or the trash) has committed
      ([](#scraps-copies)). If the receiver cannot be reached, the change still succeeds, the answer
      (`200`) carries `"unpublish": "pending"`, and the next reconcile finishes the deletion.
    - Undelete and revert keep the flag as it is.
    
    `GET /api/pages/{key}` includes `public` (`true` or `false`), `published_at` and `public_url`
    (`/p/` while the page is public and the site is set, else null), and
    `site`, the site's base URL (`SCRAP_PUBLISH_SITE`, or null). The PATCH and undelete answers
    carry the same four.
    `published_at` is not a database column: it is read from the kept copy's `meta.json`, and is
    null when there is no kept copy (never published, or unpublished). A page with `public` on and
    `published_at` null shows its badge as "Public · not published yet". That is only a null
    timestamp, not stale tracking.
    
    ### `PUT /api/pages/{key}/published`
    
    Publish the page. The body is JSON, at most 25 MiB:
    
    ```json
    {"html": "...", "base_rev": 42, "defer": false, "confirmed": true,
     "drawings": [{"key": "17", "svg": "..."}],
     "diagrams": [{"key": "1", "light": "...", "dark": "..."}]}
    ```
    
    `html` is the page from the publish mode, and `base_rev` the page's revision the PWA rendered.
    `drawings` (optional, at most 20) holds the SVG of each drawing the page shows, by its id
    (`key`, `[1-9][0-9]{0,17}`, each once): exactly those its `scrap-drawing:` placeholders name
    ([](#drawings)). `diagrams` (optional, at most 20) holds each Mermaid diagram's two SVGs, by
    its key (`[1-9][0-9]{0,2}`, each once): exactly those its `scrap-diagram:` placeholders name
    ([](#diagrams)).
    With `defer: true` scrap pushes the page's own unit but not `home`, the tag units or
    `pagefind` (Refresh all, below). `confirmed` (optional, a boolean) says the owner has just
    agreed to the page going public: the PWA sends it only from Make public's warning (and its
    Undo) and Refresh all's question. A page with no kept copy (`published_at` null) is published
    only with it.
    
    Under scrap's lock, scrap reads the page again, then checks ([](#what-scrap-sends-for-a-page)),
    replaces the placeholders, builds the unit and pushes it. `200` with
    `{"url": "/p/", "published_at": "", "files": }`
    (`url` null without `SCRAP_PUBLISH_SITE`). If the page's unit went out but `home`, the tag
    units or `pagefind` could not be pushed after it, the answer is still `200`, with
    `"derived": "pending"`: the next reconcile pushes them. The same goes for any error once the
    page's unit is pushed (Pagefind failing or timing out, say): it is logged, never a `500`.
    
    | Status | `error` | When |
    | --- | --- | --- |
    | `409` | `not_public` | The page is not `public`, or it is in the trash |
    | `409` | `unconfirmed` | A first publish (no kept copy) without `"confirmed": true` |
    | `409` | `stale` | `base_rev` is not the page's current revision (the answer has `rev`, the current one) |
    | `413` | `too_large` | The body is over 25 MiB |
    | `422` | `bad_request` | The body is not JSON `{"html", "base_rev", "defer", "confirmed", "drawings", "diagrams"}`, or a drawing or diagram is sent twice |
    | `422` | `bad_html`, `bad_placeholder` | The HTML fails a rule, or a placeholder names a file that is not on the page (or an `img` a file that is not an image), a drawing that is not one of the page's, or Open in Excalidraw without an `https` `SCRAP_PUBLISH_SITE`; `detail` says which |
    | `422` | `bad_drawing` | A drawing the page shows was not sent, one was sent that it does not show, or one's SVG has no single Excalidraw scene, one that cannot be read, or cannot be made safe ([](#drawings)) |
    | `422` | `bad_diagram` | A diagram the page shows was not sent, or not shown in both themes, one was sent that it does not show, or one's SVG cannot be made safe or names scrap in its style ([](#diagrams)) |
    | `422` | `too_many_files`, `file_too_large`, `unit_too_large` | Over a cap ([](#placeholders-and-files)) |
    | `422` | `stale_tags` | A `scrap-tag:` names a tag the page no longer has (a tag edit raced the render); the PWA renders again |
    | `502` | `publish_failed` | The receiver refused or failed; `detail` holds its `error` code |
    | `503` | `not_configured` | Publishing is off (no `SCRAP_PUBLISH_TO` or token) |
    | `503` | `busy` | A photo's preview is being made; try again (`Retry-After: 5`) |
    
    A receiver `503 busy` is retried for up to 10 seconds before `502`. A publish that meets a
    photo's preview still being made answers `503 {"error": "busy"}` with `Retry-After: 5`.
    
    ### `GET /api/public`
    
    The public pages not in the trash: `[{"id", "slug", "title", "published_at"}]`, with
    `published_at` null for a page not published yet. `?links_to=` lists only the public pages
    that refer to that page, from scrap's own link data, not the published HTML (where a link to a
    private page is only a label): those whose text or current write-up links to it (wiki links
    and `/p/` addresses: its Linked from), and those related to it either way (Claude's picks).
    The PWA uses it to decide which links in a page stay live, to find
    the pages to render again when a page is made public or private, and for Refresh all.
    
    ### `POST /api/public/rebuild`
    
    Build `home`, every tag unit, `pagefind` and `assets` once, and push those whose digest
    changed: `200` with `{"units": []}` (and `"derived": "pending"` when the
    search index could not be built), or `502` and `503` as for a publish. Refresh all sends
    each public page with `defer: true`, then calls this once.
    
    ## What scrap sends for a page
    
    scrap **checks** the uploaded HTML, refusing anything that does not pass with `422` (it never
    cleans the HTML), **replaces** its placeholders, adds the page's files, and pushes the unit.
    
    ### Parsing
    
    - The HTML is parsed with a WHATWG-conformant parser (html5lib in Python), never a regular
      expression, `html.parser` or `lxml.html`, so scrap checks the tree a browser would build.
    - Elements and attributes are matched as (namespace, local name), HTML, MathML or SVG. Anything
      not listed below is refused, which covers `xlink:href`, an SVG `a` and an SVG `style`.
    - Values are checked after entity decoding. URL rules use `re.fullmatch`.
    - Comments are dropped or allowed. CDATA sections, processing instructions and `template` are
      refused, as is an attribute given twice on one element.
    - "A class includes `katex`" means `katex` is one of its whitespace-separated tokens.
    - A `style` value is parsed as declarations, `property: value` separated by `;`, with spaces
      after `:` and a trailing `;` allowed.
    - A placeholder (`scrap-blob:`, `scrap-tag:`, `scrap-drawing:`, `scrap-excalidraw:` or
      `scrap-diagram:`) anywhere but the places allowed below, in text
      or another attribute, is refused.
    
    ### The HTML scrap accepts
    
    The document is `` with `html`, `head`, `title` and `body`; `html`, `head` and
    `body` may have only `lang` and `dir`. In `head`, only:
    
    - ``;
    - ``;
    - the CSP, which must be present:
      ``.
      Its parsed `content`, with whitespace normalised, must equal that exactly;
    - `` (pagehtml's page CSS, moved out of the file),
      and, for a page with maths, ``;
    - the site's icons (#527), each at most once and exactly so (its attributes in any order):
      `` and
      ``. No other `link`;
    - at most one `script`, which must be exactly `src="/assets/prefs.js"` with `defer` or `async`
      (one, with no value), and `blocking="render"` if given: no other attribute (no `type`,
      `integrity`, `nomodule`) and no text. The publish mode writes
      ``. Any other `script`, in the
      head or the body, is refused.
    
    There is **no `