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
43. Public pages: a static site scrap pushes to.
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 |
|---|---|---|---|
|
|
|
Where it listens, |
|
|
unset |
A file holding the token (its content, stripped). Wins over |
|
|
unset |
The token itself, for tests and development only |
|
|
|
The site folder (layout) |
|
|
2 GiB |
The site’s quota, in bytes |
|
|
|
|
The receiver does not start without a token, with one shorter than 32 characters, or with one holding whitespace.
scrap:
Variable |
Meaning |
|---|---|
|
The receiver’s base URL, e.g. |
|
The site’s public base URL, e.g. |
|
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-continuewith 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, and503 busy, since the write lock is tried from the headers before100 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 sendExpectstill sees the answer rather than a reset.Every route except
GET /healthzneedsAuthorization: Bearer <token>, compared withhmac.compare_digest. Without it the answer is401withWWW-Authenticate: Bearer, before any other check: an unknown path is401too, not404. The token, and theAuthorizationheader, are never logged; the log has the request line and status only.Every error is JSON,
{"error": "<code>", "detail": "<a sentence>"}(Errors).A wrong method on a known route is
405withAllow.
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 |
|
|
its images and files (below) |
|
A tag |
|
|
nothing else |
|
The home page |
|
|
any other one-segment |
|
The search index |
|
at least one file |
Pagefind’s files and |
|
Shared assets |
|
at least one file |
the site’s CSS, KaTeX’s fonts, |
|
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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
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 as400 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 (
aanda/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 |
|
One file |
20 MiB |
|
The request body |
256 MiB |
|
The site |
|
|
Receiving a request (headers and body) |
30 s |
|
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
<path> NUL <hex sha256 of the file's content> LF
concatenated. In 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:
{
"version": 1,
"bytes": 12345678,
"quota": 2147483648,
"units": {
"p/my-whiteboard": {"digest": "<hex>", "files": 3, "bytes": 812345, "at": "2026-10-31T14:02:11Z"},
"home": {"digest": "<hex>", "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/<id>/ and fsynced, moved to units/<id>/ with the unit’s
manifest entry beside it, then a new symlink at www/<unit> 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 |
|
When |
|---|---|---|
|
|
A malformed |
|
|
The body ended before |
|
|
The body is not a readable tar, has a GNU or sparse member, or a header over 64 KiB |
|
|
|
|
|
No token, or the wrong one |
|
|
No such route |
|
|
A method the route does not take, or an unknown method (with |
|
|
Receiving the request took over 30 s, or stalled |
|
|
No |
|
|
Over a limit |
|
|
The request line is too long |
|
|
The wrong |
|
|
Not a unit |
|
|
A member’s type, path or extension is not allowed, it is there twice, or it is a folder too |
|
|
A page, tag or home unit without |
|
|
A tar with no files |
|
|
A |
|
|
The headers are too large |
|
|
Anything unexpected in the receiver |
|
|
Another write holds the lock (with |
|
|
Not HTTP/1.x |
|
|
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/<slug>,www/t/<key>,www/home,www/pagefind,www/assets) to its copy;units/<id>/andunits/<id>.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 |
|---|---|
|
|
|
|
|
from nginx’s own config: |
|
|
|
|
|
|
|
|
|
|
anything else |
|
<path> and <name> 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: nosniffReferrer-Policy: no-referrerX-Robots-Tag: noindex, nofollow(left out withINDEXING=on)
Content-Security-Policy, by path:
Path |
|
|---|---|
|
|
|
|
|
|
everything else (home, |
|
every |
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/<slug>/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. A304for a page saysno-cachetoo (nginx knows HTML there by its file,.html, as a304has noContent-Type).The site’s own CSS and script,
/assets/*.cssand/assets/*.js(page.css,katex.css,prefs.js):Cache-Control: no-cachetoo. Every page names them at the same addresses, so a browser asks again on each load, with theETagnginx sends, and gets a short304when nothing changed; a page republished by a new release never shows with the last release’s stylesheet (#534). It isno-cache, notno-store, so browsers and Cloudflare still keep a copy to revalidate.Behind Cloudflare, the zone’s Browser Cache TTL rewrites that
no-cachetomax-age=14400unless a Cache Rule for the site’s host sets Browser TTL: Respect origin TTL. Check withcurl -sI https://<host>/assets/page.css: it showsno-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:
<a href="scrap-tag:<tag, URL-encoded>">, which scrap turns into/t/<key>(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.renderToStringwrites it (KaTeX's markup).Mermaid diagrams drawn, as the app draws them, light and dark (Diagrams); one the PWA cannot draw as its source, in
<pre><code class="language-mermaid">.Heading and footnote
ids, so#...links within the page work.A task’s box (#539), as an empty
<span>in place of the app’s<input>: in the<li class="task">,<span class="taskbox" role="img" aria-label="To do"></span>, or<span class="taskbox done" role="img" aria-label="Done"></span>when ticked.page.cssdraws 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:
<a href="/p/<slug>">, which works on the site as it does in scrap.To a private page: a plain
<span>holding the label the owner typed. With no typed label (a bare[[slug]], or a bare/p/<slug>address) the text is that page’s title, as plain text: a<span>, 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:andhttps:links as written, within the link rules.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 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. MCP never publishes HTML (ADR 43).
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:
<img src="scrap-blob:<sha>" alt="...">. Thealtis 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/mcpupload) has Claude’s label, the alt it gave, instead.A file:
<a href="scrap-blob:<sha>">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.
Claude’s reading (#521): under each photo or image, and each PDF, that has one, the app’s
fold, published closed (the one <details> without open; every other fold is published
open):
<details class="reading"><summary>Claude's reading</summary><div class="claude">
<p>...the photo's description...</p>
<details class="readtext"><summary>Text read</summary><pre>...the text read
from it...</pre></details>
</div></details>
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 <pre> 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), <link rel="icon" href="/assets/icon.svg" type="image/svg+xml"> and
<link rel="apple-touch-icon" href="/assets/icon-192.png">, and
<script src="/assets/prefs.js" defer blocking="render"></script>
(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 withoutconfirmed(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 (scrap's 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
(<SCRAP_PUBLISH_SITE>/p/<slug> 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:
{"html": "<!DOCTYPE html>...", "base_rev": 42, "defer": false, "confirmed": true,
"drawings": [{"key": "17", "svg": "<svg ...>...</svg>"}],
"diagrams": [{"key": "1", "light": "<svg ...>...</svg>", "dark": "<svg ...>...</svg>"}]}
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": "<SCRAP_PUBLISH_SITE>/p/<slug>", "published_at": "<UTC ISO 8601>", "files": <n>}
(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 |
|
When |
|---|---|---|
|
|
The page is not |
|
|
A first publish (no kept copy) without |
|
|
|
|
|
The body is over 25 MiB |
|
|
The body is not JSON |
|
|
The HTML fails a rule, or a placeholder names a file that is not on the page (or an |
|
|
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) |
|
|
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) |
|
|
Over a cap (Placeholders and files) |
|
|
A |
|
|
The receiver refused or failed; |
|
|
Publishing is off (no |
|
|
A photo’s preview is being made; try again ( |
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=<slug> 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": [<each unit pushed>]} (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.parserorlxml.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 SVGaand an SVGstyle.Values are checked after entity decoding. URL rules use
re.fullmatch.Comments are dropped or allowed. CDATA sections, processing instructions and
templateare refused, as is an attribute given twice on one element.“A class includes
katex” meanskatexis one of its whitespace-separated tokens.A
stylevalue is parsed as declarations,property: valueseparated by;, with spaces after:and a trailing;allowed.A placeholder (
scrap-blob:,scrap-tag:,scrap-drawing:,scrap-excalidraw:orscrap-diagram:) anywhere but the places allowed below, in text or another attribute, is refused.
The HTML scrap accepts#
The document is <!DOCTYPE html> with html, head, title and body; html, head and
body may have only lang and dir. In head, only:
<meta charset="utf-8">;<meta name="viewport" content="...">;the CSP, which must be present:
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' data:; style-src 'self'; style-src-attr 'unsafe-inline'; font-src 'self'; script-src 'self'">. Its parsedcontent, with whitespace normalised, must equal that exactly;<link rel="stylesheet" href="/assets/page.css">(pagehtml’s page CSS, moved out of the file), and, for a page with maths,<link rel="stylesheet" href="/assets/katex.css">;the site’s icons (#527), each at most once and exactly so (its attributes in any order):
<link rel="icon" href="/assets/icon.svg" type="image/svg+xml">and<link rel="apple-touch-icon" href="/assets/icon-192.png">. No otherlink;at most one
script, which must be exactlysrc="/assets/prefs.js"withdeferorasync(one, with no value), andblocking="render"if given: no other attribute (notype,integrity,nomodule) and no text. The publish mode writes<script src="/assets/prefs.js" defer blocking="render"></script>. Any otherscript, in the head or the body, is refused.
There is no <style> element anywhere: every one is refused.
Text never holds a private address. Text, tails and comments, lower-cased, are refused if they
contain /api/blobs/ anywhere, or one of scrap’s own hosts (public.public_origins(), lower
case, a trailing . removed) standing alone: matched by
(?<![a-z0-9.-])<host>(?![a-z0-9-]|\.[a-z0-9]), so my.<host>, <host>.org and <host>-2
pass, while <host>. ending a sentence, <host>:8446/p/x and https://<host>/api/x are
refused. (The publish mode writes “a private link” in their place.)
In body, only pagehtml’s kept elements (a abbr article aside b blockquote br caption code dd del details div dl dt em figcaption figure h1 h2 h3 h4 h5 h6 hr i img ins kbd li mark ol p pre q s section small span strong sub summary sup table tbody td tfoot th thead time tr u ul, and
input and label only as a photo’s tabs, below; a span with a role, or with the class
taskbox, only as a task’s box, exactly as above, empty), and,
inside a span whose class includes katex (“inside KaTeX” below), KaTeX’s markup:
MathML:
math semantics annotation mrow mi mn mo mtext mspace msup msub msubsup mfrac msqrt mroot mover munder munderover mtable mtr mtd mstyle mpadded mphantom menclose. KaTeX puts MathML insidemoandmtext(\pmod,\operatorname), which are text integration points, so a browser (and html5lib) parses those children as HTML elements, which mean nothing there: inside KaTeX, an HTML element with one of these names is checked as MathML;SVG:
svg,pathandline, which it draws stretchy symbols with.
Attributes:
Attribute |
Where |
Value |
|---|---|---|
|
any kept HTML element |
any text |
|
MathML inside KaTeX |
any text (KaTeX’s |
|
|
|
|
|
1 to 3 digits |
|
|
none |
|
|
any text, or empty |
|
|
a |
|
|
|
|
|
empty |
|
|
|
|
|
|
|
|
|
|
|
the same pattern |
|
|
empty |
|
|
the same pattern, and the |
|
|
|
|
|
|
|
|
any text (as body text: no private address) |
|
|
|
|
|
|
|
|
the declarations below |
|
|
the MathML or SVG namespace |
|
|
as KaTeX writes them, with no |
|
|
as KaTeX writes it |
|
|
as KaTeX writes them |
|
MathML inside KaTeX |
as KaTeX writes them, with no |
Links scrap accepts#
An a’s href must fully match one of:
an absolute
http:orhttps:URL, unless:its host is one of scrap’s own origins (
public.public_origins(): the addresses inSCRAP_PUBLIC_URLand the like), compared case-insensitively with a trailing.removed;its path starts with
/api/, whatever the host;it has userinfo (
user@oruser:pass@before the host);
mailto:..., or#<id>;/p/<slug>of a page that is public,/t/<key>, or the site’s own/and/tags;a placeholder,
scrap-blob:<sha>,scrap-tag:<tag>,scrap-drawing:<id>orscrap-excalidraw:<id>.
An https://excalidraw.com/... link is an ordinary web link. The one scrap writes itself, Open
in Excalidraw, is uploaded as scrap-excalidraw:<id> and made by scrap (Drawings): exactly
https://excalidraw.com/#url= and the encodeURIComponent form of the drawing’s SVG at
SCRAP_PUBLISH_SITE/p/<slug>/img-<n>-<hex>.svg, so it can name nothing else.
A relative /api/... is always refused, as are //... and /\... (a browser reads both as
another host), javascript:, data: and anything else. There is no rule about private
(RFC 1918) addresses.
KaTeX’s markup#
The publish mode writes each formula as katex.renderToString(tex, options), with the PWA’s
options from web/src/math.ts (trust: false, throwOnError: true, strict: "ignore"), so
the markup is KaTeX’s own string, not the browser’s serialisation of the page. The element,
attribute and style lists here are derived from KaTeX 0.16.47, the version the PWA bundles:
every setAttribute(...) and every .style.<property> = in katex.mjs, checked against both
halves (HTML and MathML) of a rendered corpus kept with the check’s tests. The attributes KaTeX
sets only with trust (href, src, alt, valign on mglyph) or only for an error it
renders instead of throwing (title) are not allowed. A new KaTeX version means deriving the
lists again.
The style declarations allowed:
Property |
Value |
|---|---|
|
a length |
|
one or two lengths |
|
a colour |
|
a length, |
|
|
|
|
|
three lengths |
A length is -?([0-9]+|[0-9]*\.[0-9]+)(em|ex|px)?. A colour is # and 3, 4, 6 or 8 hex digits,
rgb(...) or rgba(...) of numbers and %, or a name of letters only (red, Goldenrod,
transparent). Any other property or value, or style outside KaTeX, is refused.
The katex gate is visual hygiene, not a security boundary. It keeps stray markup and
inline styles out of the page’s own content, but a page could wrap anything in a katex span.
What keeps a public page safe is the CSP (the page’s meta CSP and nginx’s, with
script-src 'self', and the receiver holding no script but Pagefind’s and prefs.js), the
allow-list of elements and attributes (one script, exactly prefs.js), and that the upload is
reachable only from the owner’s PWA session.
Placeholders and files#
scrap-blob:[0-9a-f]{64}names a stored file by its sha. It is allowed only as the whole value of animg’ssrcor ana’shref, and must name a file on the page: one referenced in the page’s text, which includes another page’s file the owner embedded. Anything else refuses the publish.scrap-tag:<tag, URL-encoded>is allowed only as ana’shref, and must name one of the page’s current tags (else422 stale_tags). scrap replaces it with/t/<key>(Tag keys).scrap-drawing:<id>(animg’ssrc, or a downloada’shref) names a drawing the page shows, andscrap-excalidraw:<id>(ana’shref) its Open in Excalidraw link (Drawings).scrap-diagram:<n>-lightand-dark(animg’ssrconly) name a Mermaid diagram’s two images (Diagrams).
What a scrap-blob becomes depends on the stored file, not only on the element:
An image is never published as its original. Whichever element names it, scrap publishes its 2048-pixel preview (a raster image) or its sanitised SVG as one
img-*file. Animg’ssrc, and ana’shrefnaming the same image, both point at thatimg-*file.Any other file named by an
ais published as stored, as afile-*. (Animgnaming a file that is not an image is refused.)
Each becomes an absolute path, /p/<slug>/<name> (absolute, because a relative link from
/p/<slug> would resolve under /p/), with the bytes beside index.html:
Stored file |
Name |
Bytes |
|---|---|---|
A photo or other raster image |
|
Its 2048-pixel preview, EXIF and GPS stripped when the preview was made |
An SVG (an image or an attachment) |
|
The stored SVG, run through scrap’s SVG sanitiser again at publish (SVGs stored before #480 were never sanitised) |
Any other file |
|
The file as stored; |
<n>countsimg-*andfile-*separately, from 1, in the order they first appear in the HTML; a stored file used more than once is published once.<hex>is the first 10 hex digits of SHA-256 of the page’s slug, a NUL byte and the bytes published: a keyed hash, so a name never reveals the stored file’s sha (the SHA-256 of a file published as stored). Neither the stored sha nor the uploaded file name is published (a link’s label may show the name).“On the page” means referenced in its text (another page’s file the owner embedded included) or attached to its captures (the files the PWA lists under the text).
Caps: at most 20 images, drawings and files together (each drawing counts once, each Mermaid diagram twice: its light and dark images), each at most 20 MiB, and at most 200 MiB for the whole unit (under the receiver’s 256 MiB body). Over any of them scrap refuses the publish with
422and a message saying which, before pushing anything.Time: the receiver allows 30 seconds to receive a request, so a unit must also be one scrap can upload in that time on its link to the receiver (200 MiB needs about 56 Mbit/s). scrap’s client (httpx2) cannot send
Expect: 100-continue, so it does not; the receiver’s lingering close still delivers an early refusal. A408is logged as a failure, and the next reconcile tries the unit again.
Drawings#
A photo’s Excalidraw drawing (#520) is drawn by the PWA when it publishes the page, and sent beside its HTML. The publish mode loads Excalidraw’s chunk only for a page that has drawings.
What the PWA sends. For each drawing the page shows, its id (the drawing’s current
version, the one the page view draws) and an SVG from Excalidraw’s exportToSvg, on white, with
its fonts inlined and exportEmbedScene on: the picture and the scene in one file. The
drawing replaces its photo, as the app’s Drawing tab shows it (#524): it is drawn alone, framed
as the photo is, with no photo under it, in the picture or in the scene (the owner’s decision,
1 Nov 2026: the scene opened in Excalidraw has no photo either, as in the app). Before sending it, the PWA
reads the SVG back with Excalidraw’s own loadFromBlob, and sends nothing if the scene does not
come back whole.
What the page holds, for a photo with a drawing:
<div class="pair"><div class="tabs" role="radiogroup" aria-label="Show">
<input type="radio" name="pair-1" id="pair-1-photo" class="tab-input"><label for="pair-1-photo">Photo</label>
<input type="radio" name="pair-1" id="pair-1-drawing" class="tab-input" checked><label for="pair-1-drawing">Drawing</label></div>
<figure class="orig">...the photo...</figure>
<figure class="drawing"><img src="scrap-drawing:17" alt="Drawing of IMG_2041.jpg"></figure></div>
<p class="drawing-get"><a href="scrap-excalidraw:17" target="_blank" rel="noopener noreferrer">Open in Excalidraw</a>
· <a href="scrap-drawing:17" download>Download</a></p>
<n> counts the page’s pairs from 1. The tabs are CSS alone (/assets/page.css, with
:has() and :checked): the drawing shows first, as in the app, the arrow keys move between
the two, and a browser without :has() shows both, one under the other, never neither. On
paper both show. Open in Excalidraw is written only when SCRAP_PUBLISH_SITE is https;
without it the one link is “Download, then open in excalidraw.com to edit”.
What scrap does with each SVG, before anything is pushed:
The id must be one of the page’s photos’ current drawings, every drawing the HTML names must be sent, and nothing else may be (
422 bad_drawing,bad_placeholder).The scene. The SVG must hold exactly one Excalidraw scene, as Excalidraw writes it: the root’s one
<metadata>, holding nothing but the comments<!-- payload-type:application/vnd.excalidraw+json -->,<!-- payload-version:2 -->,<!-- payload-start -->and<!-- payload-end -->in that order, with base64 between the last two; and no CDATA section anywhere. The base64 is that of{"version": "1", "encoding": "bstring", "compressed": true, "encoded": ...}, written one byte a character, andencodedis the zlib deflate of the scene’s JSON (Excalidraw 0.18,data/encode.ts). scrap decodes it (at most 24 MiB inflated, 32 levels deep) and keeps only:type,version,elementsandappState,filesas{}, andsourceasscrap(Excalidraw writes the address of the app it ran in, which is scrap’s own);of
appState, the background colour and the grid;every element not deleted and not an image, with only Excalidraw’s own element fields (no
customData, and none of an image’s), and inside them only keys that are plain names. Every string in an element loses any embeddeddata:URL and goes through the page text’s rule: an address of scrap’s own, or an/api/path, becomes “a private link”. An element’slinkfollows the page’s link rules: a web ormailto:link stays, a public page’s becomes its address on the site, and anything else (a private page, scrap’s own,/api/) is removed;no image, and no file: every image element is dropped, the page’s photo (as 1.3.1’s PWA put under the drawing) or any other, so no photo bytes, original or preview, go out in a drawing.
The picture. The SVG goes through scrap’s SVG sanitiser (as any SVG; up to 20 MiB here, as 1.3.1’s PWA sent the photo in it twice), which drops the old scene. In what is drawn, every
<image>and<feImage>is removed, and every element whoseidor fragmenthrefnames an image symbol (image-...: Excalidraw’s symbols and their uses); every text and attribute value is scrubbed by the same rule; and its<style>may embed only whole WOFF2 or WOFF fonts (as Excalidraw inlines them; the font’s header must give the data’s own length) and no address of scrap’s, else the drawing is refused. Text Excalidraw writes is one<text>a line: an address split across elements is not joined first.The cleaned scene goes back where Excalidraw puts it, first in the SVG (
<!-- svg-source:excalidraw --><metadata>...</metadata>), so the file is both the picture and a drawing.
The request’s own limit, 25 MiB, holds the HTML and every drawing: a drawing is mostly its inlined fonts, a few hundred kB, so the 20-file cap comes first.
It is published as img-<n>-<hex>.svg, numbered with the page’s images, under the image CSP
(sandbox, loading nothing). scrap-drawing:<id> becomes its path, and
scrap-excalidraw:<id> becomes https://excalidraw.com/#url= and the SVG’s address on the site,
encoded as encodeURIComponent does.
When it changes. Saving a drawing in the PWA publishes its page again, as saving the page
does. A drawing changed elsewhere (by Claude, or over MCP) keeps its published copy until the
page is next saved in the PWA, or Refresh all. A drawing replaced between the PWA drawing the
page and scrap checking it is no longer the page’s (422 bad_placeholder): the PWA draws the
page again once, as for a stale tag.
Opening it. excalidraw.com reads a #url= address on load, fetches that file in the
reader’s browser (hence the SVG’s Access-Control-Allow-Origin) and opens it as it opens a file
dropped on it, with loadFromBlob, which takes it as an SVG only if the answer’s type is
exactly image/svg+xml (hence no charset, nginx). Nothing is uploaded to Excalidraw’s servers. #url= is not a
documented feature of excalidraw.com and could change; Download is the fallback: the SVG opens in
excalidraw.com, or a copy of Excalidraw, with its own Open.
Diagrams#
A Mermaid diagram (#525) is drawn by the PWA when it publishes the page, as the app draws it,
and sent beside its HTML. The publish mode loads Mermaid’s chunk only for a page that has one
(a ```mermaid block, drawn on the screen or not yet).
What the PWA sends. For each diagram, two SVGs from the same Mermaid, with the app’s
options (securityLevel: "strict", theme neutral for light and dark for dark), plus
htmlLabels: false, so its labels are SVG <text>, never a <foreignObject> (the sanitiser
drops one, and its words with it); the font named as tokens.css’s --font stack (an <img>
inherits none); and the root’s width and height set from its viewBox, its style
removed, so the image has a size of its own. Diagrams are keyed 1, 2, … by their sources,
in order (the same source twice is one diagram). One Mermaid cannot draw so is left out, and
the page shows its source as a code block, as before (the reason goes to the console): a user
journey (journey), whose labels Mermaid always writes as HTML, and one whose own
configuration turns HTML labels back on. Each diagram is two of the page’s 20 files, so a page
with more diagrams than the cap leaves room for, after its images and files, publishes the
rest as their source. A gitGraph without its own commit ids gets new random ones from
Mermaid each time it is drawn, so its two images differ in those, and it is a new file on each
publish.
What the page holds:
<figure class="diagram"><img class="light" src="scrap-diagram:1-light" alt="Diagram: Phone,
share, Offline queue"><img class="dark" src="scrap-diagram:1-dark" alt="Diagram: Phone, share,
Offline queue"></figure>
The alt is “Diagram: “ and the words drawn in it, each label once (at most 300 characters),
scrubbed as a code block’s text.
/assets/page.css shows one, by the page’s theme: the light one unless data-theme="dark"
(Reader preferences), so with no script, or on paper, it is light. Centred and scaled
down to the column, as the app’s. There is no Source fold: search reads the
diagram’s words from the SVG instead (What scrap builds).
What scrap does with each SVG, before anything is pushed: every diagram the HTML names must
be shown in both themes and sent, and nothing else may be (422 bad_diagram). Each SVG (at most
2 MiB) goes through scrap’s SVG sanitiser (no script, foreignObject, event handler, link out
or fetching CSS); every text and attribute value is then scrubbed as a code block’s
(What the publish mode writes: only scrap’s own addresses), and a <style> that would
change is refused. Each is published as an img-<n>-<hex>.svg, numbered with the page’s
images, under the image CSP, and counts toward the caps as an image of its own.
Reader preferences#
Every page, tag page, the home page, Tags and the 404 load one script from their head,
/assets/prefs.js, deferred and blocking="render": in Chromium-based browsers that blocks
the first paint until it has run, so a kept dark theme shows from the first frame. Browsers that
do not know blocking (Firefox and Safari, today) may paint once before it runs, so a reader
who chose dark can see a light flash there. It keeps two choices in the reader’s browser (localStorage), and puts two buttons at
the end of the site’s header:
The theme. Public pages are light, whatever the device’s
prefers-color-scheme. The button, a moon or a sun, named “Dark theme” and pressed while dark, switches to dark and back; dark isdata-theme="dark"on<html>, set again on every page from the kept choice (scrap.public.theme).The column’s width. As the app’s Settings → Maximum column width: Narrow (720px), Medium (800px, the default,
tokens.css’s--column), Wide (880px) and Wider (960px), set as--columnon<html>(scrap.public.column). The button shows the width and cycles through them, named “Column width: Medium (800px)” and so on. It is hidden where the window is narrower than the narrowest column and its gutters, where the choice makes no difference.
Each button is a 48 px target with a visible focus ring, in ink, not amber. Without the script there are no buttons: the page is light, at the default width.
Tag keys#
A tag’s key is computed only in Python, by scrap. It is not slug_base, the rule for a
page’s slug:
Take the tag as stored.
his the first 8 hex digits of the SHA-256 of its UTF-8 bytes.Normalise it to Unicode NFKD, encode it as ASCII dropping every character that has no ASCII form, and lower-case it.
Replace each run of characters other than
a-zand0-9with one-, and strip-from both ends.Cut it to 60 characters and strip
-from the end again.The key is that slug,
-, thenh; orhalone if the slug is empty.
So personal/family is personal-family- followed by 8 hex digits (for example
personal-family-1a2b3c4d). A key is at most 69 characters, inside the t/ pattern, and two tags
with the same slug still get different keys.
What scrap builds#
The derived units are built only from the kept copies and their meta.json
(scrap's copies), never from anything else, with plain Python templates and no Markdown:
home:index.html(the public pages bypublished_at, newest first, with their tags and Pagefind’s search box),tags.html(every tag a public page has, with its pages) and404.html;a
t/<key>unit for each tag a public page has: itsindex.html, listing the tag’s pages;pagefind: the index, built by thepagefind[bin]Python package at a pinned version, plus Pagefind’s UI files andscrap-search.js, which starts the UI. Each page is indexed from an in-memory copy with its.katexelements removed and the formula’s TeX (from itsannotation encoding="application/x-tex") put in their place, so search finds the TeX, not KaTeX’s glyph spans, a Mermaid diagram (figure.diagram) as the words of its light image’s SVG (its<text>,<title>and<desc>, each once, a label’s wrapped lines joined with a space), and without the site’s lines and the page’s header (title, meta and dates lines, tag chips), so a result’s excerpt is the page’s text. The unit is cached, keyed on a digest of the pinned version,scrap-search.jsand every record, so an unchanged index is not built again; a build has about 120 seconds, and if it fails or runs out the site keeps its last index ("derived": "pending");assets:page.css(pagehtml’s page CSS),katex.css, KaTeX’s fonts,prefs.js(Reader preferences) and the app’s own icons,icon.svgandicon-192.png(#527), from scrap’s own static build of the PWA, and Pagefind’s UI stylesheet, as CSS lives only inassets.
Each of the site’s own pages loads /assets/page.css and /assets/prefs.js and names the two
icons from its head, as a published page does.
Every page’s header, a published page’s site name and the site’s own “Pages · Tags”, starts
with scrap’s mark (#527): /assets/icon.svg, about the line’s height, drawn by page.css alone
(a ::before background on the header’s first link, under img-src 'self'), so the markup, the
check and the receiver are unchanged. The header keeps one row: a name too long for it is cut
with “…” rather than wrapping under the theme and width switches.
Titles and tag names come from meta.json. Every build is deterministic: the same copies give
the same bytes (sorted order, no timestamps), so an unchanged unit has an unchanged digest.
scrap’s copies#
For each published page scrap keeps the unit exactly as it last pushed it, under
<data>/cache/publish/p/<slug>/: index.html (placeholders replaced) and every img-* and
file-*, with meta.json beside them (not part of the unit):
{"id": "...", "title": "...", "tags": ["..."], "published_at": "<UTC ISO 8601>"}
tags are the page’s tags in the database at publish time. It is derived data, not
backed up. scrap also stores the digest it last pushed for every unit; a kept copy is damaged
when the digest of its files, computed again, differs from that stored digest.
One lock in scrap is held over every publish, unpublish and reconcile step, so they never interleave.
Publish: read the page again and refuse unless it is public and not in the trash (
409 not_public); check and build the page unit, push it, write the copy,meta.jsonand the digest; then, unless deferred, buildhome, the page’s tag units (before and after) andpagefind, and push those whose digest changed.Unpublish (Public off in the PWA or over MCP, a revert, the page going to the trash), after that change has committed: delete scrap’s copy first, then
DELETE /v1/units/p/<slug>, then build and pushhome, the page’s tag units (deleting a tag unit no public page has any more) andpagefind. If the receiver cannot be reached, the copy is still deleted and the next reconcile removes the unit.
Reconciling#
At startup and every 10 minutes, under the lock, scrap does the following. After a failure it tries again sooner, backing off: 30 seconds, doubling up to 10 minutes, and back to every 10 minutes after a success (so a receiver still starting is caught quickly).
prunes: deletes the kept copy, and its unit, of every page that is not public or is in the trash, and the kept copy and unit of every damaged copy (scrap's copies);
builds every derived unit fresh (
home, everyt/*,pagefind,assets) from the kept copies that remain. This also picks up a new KaTeX, new templates or a new Pagefind after an upgrade, and a prune always leads to it;reads
GET /v1/manifestand pushes every unit that is missing or whose digest differs: a page unit from its kept bytes (the manifest’s digest is compared with the digest scrap last pushed and stored first, and the copy’s files are read only when they differ), a derived unit from the fresh build. A page’s copy is re-read under the lock before itsPUT;ends with
POST /v1/synclisting the units to keep, which deletes any stray.
No rendering is involved, so a site that lost its files (a restarted pod’s emptyDir) is
refilled at the next run without the PWA. A page whose damaged copy was pruned comes back at the
next Refresh all. A 503 is retried; any other failure is logged and left to the next run.