# Server API v1

JSON over HTTPS. The public server is `https://hushslop.getparkerai.com`, run by the [own server](#own-server) build, and its signed list is `https://hushslop.getparkerai.com/list.bin`, with the same list as JSON at `/list.json`. The project was called SecondLook until the rename, and `https://secondlook.getparkerai.com` is the same server under its old name: it answers everything this one does, since 0.0.2 builds have it built in. Development server: `npm run server`, the own server with keys it makes at start, on `http://127.0.0.1:8787` (Android emulator: `http://10.0.2.2:8787`). Keys, hashes and labels are defined in `keys.md`. The [Report Worker](#report-worker) takes the same report bodies and publishes the list to GitHub instead.

## GET /v1/health

```json
{ "ok": true, "name": "HushSlop", "version": "0.0.2" }
```

## POST /v1/reports

Records or changes this install's vote on one post. Voting again on the same key replaces the earlier vote.

```json
{
  "installId": "6f1c2a4e-1b7d-4c1e-9f0a-2d3e4f5a6b7c",
  "platform": "facebook",
  "keys": ["fb:post:pfbid02abc...", "fb:photo:123"],
  "authorKey": "fb:author:somepage",
  "url": "https://www.facebook.com/somepage/posts/pfbid02abc...",
  "vote": "ai"
}
```

| Field | Rule |
|---|---|
| `installId` | UUID v4 the client makes once and keeps |
| `platform` | `facebook`, `tiktok`, `instagram`, `youtube`, `x` or `reddit` |
| `keys` | 1 to 8 post keys for the same post (a post can have both a `pfbid` and a numeric id), all with the prefix of `platform` (`fb:`, `tt:`, `ig:`, `yt:`, `tw:` or `rd:`) |
| `authorKey` | optional author key of the same platform (`fb:author:`, `tt:author:`, `ig:author:`, `yt:author:`, `tw:author:`, `rd:author:`) |
| `url` | optional, `https`, a host of that platform (see [keys.md](keys.md)), at most 2048 characters |
| `vote` | `ai` or `not_ai` |
| `reportId` | optional UUID v4, new for each report. The Worker counts each one once a day per install, so a client can resend after a timeout |
| `proof` | optional, a whole number in decimal with no leading zeros and at most 16 digits. Needed only when the server asks for one ([Proof of work](#proof-of-work)) |
| `warning` | optional, the `at` of a warning the reporter read and said they understood ([Warnings](#warnings)). A whole number |

Response `200`:

```json
{ "accepted": true, "entries": { "3f9a0c1d2e4b5a67": [1, 4, 0] } }
```

`entries` holds the current label of every key in the report that has one, keyed by `keyHash` in the list format 2 shape ([Publishing](#publishing)), so the client can update the banner without waiting for the next list. A resent `reportId` gets `{ "accepted": true, "duplicate": true, "entries": {...} }` and changes nothing. `url` is checked and then dropped. The server never stores a link, since Instagram's `igsh` parameter says who shared it. Short-link keys count on the post they lead to ([Short links](#short-links)).

Errors: `400 { "error": "invalid", "detail": "..." }`, `403 { "error": "proof_needed", "bits": 20, "detail": "..." }` from a server that asks for a proof of work, `403 { "error": "warned", ... }` for an install the owner warned ([Warnings](#warnings)), `413` for a body over 8 KB, `429 { "error": "rate_limited", "retryAfter": 120 }`.

Clients word the refusals a person can run into in the reader's language: a `proof_needed` they can't meet (bits over 28, or bits the proof they sent already holds), and `name_locked` and `bad_name` from [Names](#names). Each code is worded only with its own status. `detail` is English and written for developers, so a client shows it only for a refusal it has no words for, and it takes a client bug to get one.

### Warnings

The owner can warn an install before banning it (`hushslop-admin warn`). Its next report is turned away, and so is every one after, until a report names the warning:

```json
{ "error": "warned", "note": "Report what looks AI-made, not what you disagree with.", "at": 1760788800000, "guidelines": "/guidelines", "detail": "..." }
```

`note` is the owner's text, at most 300 characters, for the reporter to read. Show it as plain text with a link to `guidelines`, a path on the same server, and an "I understand" button. The button sends the same report again with `"warning": at`, which lifts the warning and counts the report. A turned-away report counts against the install's limit and keeps nothing. Warning again replaces the note and the `at`, so a stale `at` gets the new warning back. A banned install hears what anyone would, so it never sees one.

## DELETE /v1/reports

Withdraws this install's votes (the Undo button). Body: `{ "installId": "...", "keys": ["fb:post:..."] }`. Response `200 { "removed": 1 }`.

## GET /v1/list

The own server's `list.json` (list format 2, [Publishing](#publishing)), served the same way as `GET /list.json` ([Own server](#own-server)) but read without its signature. Every client reads it from here when its list address is empty ([Reading the list](#reading-the-list)). The Worker doesn't have it.

## Limits

60 reports an hour per install, with more limits by network and for everyone together ([Reporters](#reporters)). Past one the answer is `429`.

## Report Worker

The Cloudflare Worker in `worker/`. It answers `GET /v1/health`, `POST /v1/reports` and `DELETE /v1/reports` with the bodies above, and `PUT /v1/name` ([Names](#names)). It has no list routes: clients download the published list from GitHub. The [own server](#own-server) runs the same handler.

It refuses a request from a web page (`403`): an `Origin` header that isn't a browser extension's, or `Sec-Fetch-Site: cross-site`. It also refuses any content type but `application/json` (`415`), a request without `CF-Connecting-IP` (`400`), and everything while its `REPORTER_BUCKET_SECRET` is unset (`503 { "error": "not_configured" }`).

### Reporters

The IP address never reaches storage. The Worker turns it into HMAC buckets for the device (an IPv4 address or an IPv6 /64) and the reporter group (an IPv4 address or an IPv6 /48), two kinds of each:

- Daily buckets, which change every UTC day. The rate limits and the published log use these.
- Post buckets, made from the post's key hash with no date in them. A post's `ai` and `notAi` counts are distinct devices by these, with at most 2 from any one group and each install weighted by its standing ([Standing and new installs](#standing-and-new-installs)), so a hundred installs on one network count as one or two reporters, and a network that comes back every day with a fresh install is still the same one. They differ for every post, so they can't tie one network's reports together, and they never leave the queue.

A changed vote keeps the buckets of the first one.

A post's author is the one named from the most groups, then by devices with the same cap, then the first one named. It takes at least 2 groups, so one network can't attach posts to an account however many installs it uses. Each install names a post's author once, and Undo takes that back with the vote.

A creator's reported posts count each post once, however many keys it was reported under (a Facebook post with two photos is reported under three). The keys of one report are one post's, but any install could send any keys together, so two keys only count as one post once installs on at least 2 networks sent them in the same report. Undo takes that back with the vote. A banned install's reports stop counting toward it, and so do an install's while its weight is 0, until the ban is lifted or the weight is back. A post that holds a key the owner ruled real is real.

Limits, each a fixed window and a Worker setting: 60 an hour per install (`LIMIT_INSTALL_PER_HOUR`), 120 an hour per device (`LIMIT_DEVICE_PER_HOUR`), 600 an hour per group (`LIMIT_GROUP_PER_HOUR`) and 1200 a minute in total (`LIMIT_ALL_PER_MINUTE`). A refused request doesn't count toward the others, and neither does a resent `reportId`. Retracts count like reports.

### Short links

A `tt:share`, `ig:share`, `fb:share`, `fb:watch` or `rd:share` key says nothing about its post until someone opens the link. The Worker does that once. It follows the redirects itself, one at a time and only to `https` addresses on that platform's own hosts, until a real post key turns up in an address, in a login page's `next` parameter or in the page's canonical link. The vote then counts on that post, and the queue keeps the token as an alias:

- The report's answer and the list give the token's hash the post's entry, so an app that only has the share link still finds it.
- Every later report with the token counts on the post, with post buckets made from the post, so a share link and the post itself count one reporter once.
- Undo with the token takes back the vote on the post without opening anything.
- The first post a token led to stays its post. When a Worker finds a different one, the queue sends the report back with the first and the Worker makes the post buckets again.

The Worker asks the queue before it opens anything, so each token gets opened once, not once per report. That question already counts the report against the limits, so nothing gets opened for a resent `reportId` or a report the limits would refuse, even with many sent at once. A report can spend 8 requests and 6 seconds on at most 2 links. A link that leads nowhere counts under its own key, and the Worker tries it again 10 minutes later.

Votes a token got before anyone could open it don't move to the post. Their buckets came from the token, and on the post they'd let one network count again, and name an author again, for every share link it made. So once the token is opened they're withdrawn, and those installs can vote again on the post.

### Rulings and bans

The list's owner can rule on a post. A ruling of AI lists it as code 5 with the crowd's counts, or `[5, 0, 0]` when nobody reported it. A ruling of real lists it as code 7, `[7, ai, notAi]`, however many people report it, so apps show it even when its creator is ruled AI. Votes still count underneath, so taking a ruling away hands the post back to them. A ruled post stays listed after its votes age out, as `[5, 0, 0]` or `[7, 0, 0]`. A short link is ruled through the post the queue knows it leads to. Each change of ruling goes into the log, and the note the owner writes with it stays private.

The owner can rule on a creator the same way, by the creator's page or author key. A ruling of AI lists the creator as code 6, `[6, reportedPosts, 0]`, whether or not three of their posts are reported, and apps treat each of their posts as ruled AI. A ruling of real keeps the creator off the list, so their reported posts no longer label them, and taking it away hands them back to the 3-post rule. Their posts keep their own entries and rulings either way. The log line has the creator's hash as its `key`. Nobody votes on a creator, so a creator's ruling grades no install. TikTok and Instagram creators have no page address HushSlop can read, so the queue keeps the author key each report sent next to its claim (deleted with the creator's last claim), and the moderation views list the creators a post's reports named, with that key, to rule on from there.

The owner can also ban an install. Every vote and author claim it has is taken back, with a `withdraw` line each. From then on its reports are answered the way anyone's would be, rate limits included, and never kept, and the Worker opens no short link for it. The queue holds the votes it took back. Lifting the ban counts them again at the install's weight then, each with a new vote line, all but any that would have aged out by then. A vote the install takes back while banned, or one on a short link that someone else follows meanwhile, isn't put back.

### Standing and new installs

When the owner first rules on a post, each vote it holds right then goes on its install's record. It's a confirm when the vote agrees with the ruling (AI with AI, real with real) and a strike when it doesn't. A vote cast after the ruling is out earns nothing, since anyone can read the ruling in the log and copy it. The record sets how much the install's votes count:

- With 3 strikes or more, and more strikes than confirms, they count for nothing.
- With 5 confirms or more and at most one strike for every four, each of its devices counts twice. The second count has its own cap of 2 per group.
- Otherwise they count once.

On a server with the bridging rule on ([keys.md](keys.md#labels)), an install that usually votes "Looks real" also needs at least 2 confirms, and more confirms than strikes, before its AI vote helps lift a disputed post.

When an install's weight changes, every vote it holds is counted again at the new one, and the list follows at the next publish. An install whose votes count for nothing has its author claims kept but not counted. Changing or taking back a graded vote, or losing it to a share link or to age, leaves the record as it was, so nobody can clear a strike with Undo. Changing a ruling grades the same votes again by the new one, and taking a ruling away takes its confirms and strikes back. The post keeps the votes it graded until its last vote ages out, so ruling it again grades those same votes and not ones cast after the first ruling. A post first ruled with no votes held graded nobody, so ruling it again grades the votes it holds then. Weights aren't in the log, which is one more reason counts rebuilt from it can differ from the list's.

For its first 7 days, counted from its first vote (`NEW_REPORTER_DAYS`, 0 turns it off), an install's votes and author claims go in one network that every new install shares. However many fresh installs report a post, they add at most 2 to it, trusted or not, and they can't name its creator, since that takes 2 groups. A vote cast in those days stays in the shared network after the install is older. A queue that already holds votes when this arrives dates each install from its oldest held vote (a banned one from its ban), and grades the votes its rulings found that were cast before them.

### Proof of work

A server can ask each install to do some work before it reports, so making installs by the thousand costs time as well as addresses. It's on when `POW_BITS` is a whole number from 1 to 28 written plainly in decimal, and off when it's `0` or unset. Anything else (`20.0`, `0x14`, `2e1`, a space) turns it off on the Worker and stops the own server at start.

A proof holds at N bits when the SHA-256 of the UTF-8 text `secondlook-proof:1:<installId in lowercase>:<proof>` starts with N zero bits. The prefix is the project's first name, and it stays, since a proof an install already found has to keep holding. Clients count up from 0 and send the first number that holds, so every client finds the same proof for an install. The `proofs` cases in `test-vectors.json` pin that, and `proofChecks` pins what a server takes. Finding one takes about 2^N hashes, once per install, and checking it takes one.

With `POW_BITS` set, a report without a proof that holds gets `403 { "error": "proof_needed", "bits": 20, "detail": "this server asks each install for a proof of work of 20 bits" }` before anything else is looked at, so it uses up no limit and nothing reaches the queue. The client finds a proof, keeps it, sends the refused report once more with it and sends it with every report after that. The search can take a phone minutes, so a client runs it away from the thread that sends and holds its queued reports behind the refused one until it's done. A `403` that asks for bits the kept proof already holds, or for bits outside 1 to 28, is a refusal like any other. Raising `POW_BITS` later has each install find a new proof at its next report. Retracts never need one. A `proof` that isn't a number in the form above gets `400`, whether the server asks for one or not.

The extension's code tries about 2 million numbers a second on a desktop PC, so 20 bits takes about half a second and 22 about two. Phones take longer. The Hush client works it out on a low-priority thread of its own, so Undo and taken-back votes don't wait for it, but its queued reports go in order and wait behind the one that asked. Keep it at 20 or under for phones. Anyone with a GPU tries billions a second, so it's a speed bump on top of the limits and `NEW_REPORTER_DAYS`, not a wall.

### Names

`PUT /v1/name` with `{ "installId": "...", "name": "Ada" }` sets the name an install shows by on the own server's [stats page](#own-server). The name is tidied first: NFC, each run of whitespace made one space, then trimmed. What's left has to be 1 to 32 code points of letters, numbers, combining marks, spaces and `. _ ' -`, starting with a letter or number and ending with one, a mark or a `.`, or it gets `400 { "error": "bad_name", "detail": "..." }`. A body whose `name` isn't a string or `null` gets `400 { "error": "invalid" }` like any other. A `null` name, or one that tidies to nothing, takes the name away, and the install shows by its code again.

The answer is `200 { "name": "Ada" }` with the name as it was kept, or `{ "name": null }` when it was taken away. A name the owner locked gets `403 { "error": "name_locked", "detail": "the owner set this name, so it can't be changed" }`. A banned install gets the `200` anyone would, and nothing it sends is kept. A name change is admitted the way a report is (no web pages, JSON only, the bucket secret set) and counts toward the same limits, but it never needs a proof of work. Names stay in the queue, and neither the log nor the list carries them.

### Publishing

Every 15 minutes a Cron Trigger publishes what's waiting as one commit to the data repo, up to 20,000 log lines at a time (`FLUSH_BATCH`), so a backlog goes out over several runs. A run with nothing new makes no commit, and a failed commit keeps everything for the next run. The Worker writes down each commit before it moves the branch, and the next run checks whether that commit landed, so a lost answer from GitHub doesn't publish a batch twice. It keeps the list it last built and redoes only the entries that votes since the last run can have moved, so a run costs about the same however long the list gets. The list still counts every vote waiting, past the batch too.

Once a day a run also drops every post whose last vote is 30 days old (`QUIET_MS`), along with its author claims and the short links that led to it, so the list stays small enough for phones to fetch every 30 minutes. A repeated vote counts as a vote. The logs keep everything, and a post that gets a vote later starts over from that vote. The same run builds the whole list again from the counts and clears out rate windows and report ids that have run out. When the rebuilt list isn't the one last published (a listed post dropped, usually), it goes out even with no log lines.

Each commit writes these files, or only the two lists and their signatures when no vote changed:

`data/reports/YYYY/MM/DD/HHMM-<seq>.jsonl`, in UTC, where `seq` is the first line's. One line per vote change:

```json
{"seq":41,"at":"2026-10-18T12:00:00.000Z","platform":"tiktok","key":"9c335e897d8e09bb","author":null,"vote":"ai","reporter":"5f0c9a61d2e4b387","group":"a19e04c2b8f7d613"}
```

`vote` is `ai`, `not_ai`, `withdraw`, `alias`, or one of the owner's rulings: `lock_ai`, `lock_real` or `unlock`. A ruling line has no author or reporter: `{"seq":43,"at":"...","platform":"tiktok","key":"<post hash>","vote":"lock_ai"}`, with the creator's hash as `key` for a ruling on a creator. An alias line goes out when the queue learns where a short link leads, and has no reporter: `{"seq":42,"at":"...","platform":"tiktok","key":"<token hash>","vote":"alias","target":"<post hash>"}`. Just before it, a `withdraw` line takes back each vote the token had, and later votes go out under the post's hash. `at` is cut to the minute. `key` and `author` are key hashes and `reporter` and `group` are that day's buckets, so the log shows every vote without naming anyone. Each `seq` goes out once. If one ever turns up in two files, keep the first. Counts rebuilt from the log can come out higher than the list's, because the list counts by post buckets, which aren't published.

`data/list.json`, list format 2:

```json
{"format":2,"version":1792324800,"generatedAt":"2026-10-18T12:00:00.000Z","entries":{
"9c335e897d8e09bb":[1,3,0],
"a1b2c3d4e5f60718":[4,3,0]
}}
```

Every post with at least one AI report and a vote in the last 30 days is listed as `[code, ai, notAi]`: the label code from keys.md and both counts (weighted by standing), so each app can apply the threshold its user picked. So is every post the owner ruled on, with code 5 for AI and code 7 for real. An author is `[4, reportedPosts, 0]`, or `[6, reportedPosts, 0]` once the owner rules them AI, and isn't listed once ruled real. A short link the Worker has followed is listed too, with its post's entry. A server that reads [outside lists](#outside-lists) also lists each X post a Community Note calls AI-made as `[8, ai, notAi]` and each channel AiSList names as `[9, reportedPosts, 0]`, and then names those sources in `sources`, ahead of `entries`: each one's `code`, `name` and `url`, with `license` and `licenseUrl` when it has one. A client doesn't need `sources` and skips it. Entries sit one to a line in hash order, so a commit's diff shows only what changed.

`version` is the second the list was built, or one past the last list's when that's higher, so a list published after the server's clock ran ahead and was set right still goes up. It's never more than a day (86,400 seconds) past the server's clock. When one past the last would be, the list starts again from the current second, which clients take ([Keeping it fresh](#reading-the-list)). `generatedAt` is the second it was built either way.

`data/list.json.sig`, a base64 DER ECDSA P-256 signature over the exact bytes of `list.json`, with no trailing newline. It's the format CallShield's feeds use, so a copy from jsDelivr can be checked against the public keys compiled into each client. `npm run keygen` in `worker/` makes the key pair.

`data/list.bin`, the compact list: the same entries and `version` in a form a phone searches where it lies, with no JSON to parse and no map to build. It's a 16-byte head and then 13 bytes for each entry, in ascending hash order:

| Bytes | Holds |
|---|---|
| 0 to 3 | `SLC1` in ASCII |
| 4 to 11 | `version`, unsigned 64-bit big-endian |
| 12 to 15 | how many records follow, unsigned 32-bit big-endian |
| each record | the hash's 8 bytes, the code in 1 byte, then `ai` and `notAi` as unsigned 16-bit big-endian |

A count past 65,535 is written as 65,535. `data/list.bin.sig` signs its exact bytes the way `list.json.sig` signs the JSON. A reader refuses the whole file when the head isn't `SLC1`, when its length isn't 16 bytes plus 13 for each record the head counts, when the version is past 9,007,199,254,740,991 (the largest whole number a double holds exactly), or when a record's hash isn't above the one before it. A record whose code is past 9 reads as no entry. The `compactLists` cases in `test-vectors.json` pin all of it.

Both lists stop at 250,000 entries (`LIST_MAX_ENTRIES`), about 7.5 MB of JSON and 3.3 MB of `list.bin`. Past that they keep the posts and creators the owner ruled on, then the posts a Community Note calls AI-made, then the entries whose AI reports outnumber their "Looks real" votes, and among those the most reported and least disputed, with the hash breaking a tie. What's kept still goes out in hash order. The Worker's stored list holds every entry, so one that was left out comes back once it ranks high enough again. The commit message says how many were left out, and so does the publish's answer as `dropped`. A server that published before `list.bin` existed, whose `LIST_MAX_ENTRIES` changed since its last publish, or that signs with a new `LIST_SIGNING_KEY`, puts both lists out again at its next run, vote or no vote, with the message `data: new list form`.

### Running the Worker

It isn't deployed yet.

```
cd worker
npm install
npm run smoke    # bundle it the way a deploy would, then run it in workerd against a fake GitHub
npm run usage    # rows the queue reads and writes per vote and per publish, which is what Cloudflare bills
npm run keygen   # once: the key that signs the lists (private half to ~/.hushslop/)
```

On the Durable Objects Free plan (5 million rows read and 100,000 written a day) the queue holds about 7,100 new votes a day, since each one writes 14 rows (13 when its install has voted before). Past that, the Workers Paid plan lifts both limits. Reads aren't the limit. A publish reads about 25 rows for each post that got a vote since the last run, however long the list is, and once a day one run reads every post to build the list again.

A key made before the rename sits in `~/.secondlook/`, since keygen wrote there until then. It still signs the same lists, so leave it where it is and point `LIST_SIGNING_KEY` at its contents as before, or move it to `~/.hushslop/`.

Its unit tests run with the rest under `npm test` at the top level. Before `npm run deploy`, set the three secrets listed in `worker/wrangler.toml` with `npx wrangler secret put` and fill in `GITHUB_REPO`.

## Own server

`host/` runs the Worker's handler and queue on Node, so `/v1/health` and both `/v1/reports` routes behave as described for the Worker, with these differences:

- The client's address comes from the connection, or from the last `X-Forwarded-For` entry with `HUSHSLOP_TRUST_PROXY=1` behind a proxy. Only a connection from a loopback, private or unique-local address is taken for the proxy. A `CF-Connecting-IP` the client sends is dropped.
- A body past 64 KB gets `413 { "error": "too_large" }`, and the rest of it is read and dropped, so the connection stays open. The Worker's 8 KB limit applies after that.
- A timer publishes every 5 minutes (`HUSHSLOP_FLUSH_SECONDS`), and once at start. The files are the Worker's, under `public/` in place of `data/`, inside the server's data folder.

It also serves what it publishes:

- `GET /list.json` and its signature `GET /list.json.sig`, as one pair from memory, so a client never gets a list with the previous one's signature. Both have an `ETag` (a `304` for `If-None-Match`), `Cache-Control: public, max-age=60` and `Access-Control-Allow-Origin: *`. Before the first publish both are `503 { "error": "not_published" }`, and so are they after a restart that finds the pair on disk doesn't verify with `LIST_SIGNING_KEY`, until the next publish. `GET /v1/list` is the same list.
- `GET /list.bin` and `GET /list.bin.sig` the same way, as a pair of their own with an `ETag` of their own and `Content-Type: application/octet-stream`. A restart checks each list against its own signature, so a bad `list.bin.sig` keeps `list.bin` at `503` while `list.json` still answers. `GET /list.bin?since=<version>` can answer with the changes since that version instead ([Changes since a version](#changes-since-a-version)).
- `GET /reports/`, `/reports/YYYY/`, `/reports/YYYY/MM/` and `/reports/YYYY/MM/DD/` list what's inside as a JSON array of names. `GET /reports/YYYY/MM/DD/HHMM-<seq>.jsonl` is one batch, which never changes once it's there. Nothing else under the data folder is served.
- `GET /database/posts.csv` is every post with votes or a ruling as one CSV file, in hash order. The first publish of each UTC day writes it again (and the first one ever writes it), into `database/` beside `public/`. The header is `post,platform,creator,label,ai,looks_real,ruling,ruled`: the post's hash, its site, the creator's hash once enough groups agree on one as for a creator's entry (empty until then), the list's code for the post on its own (`7` when it's ruled real, `0` when it isn't listed), its two counts, the owner's ruling (`ai`, `real` or empty) and the UTC day of it. Nothing else goes in, so no install ID, network code, ruling note or anything from an appeal. It comes with `Content-Type: text/csv; charset=utf-8`, `Last-Modified`, `Cache-Control: public, max-age=3600` and `Access-Control-Allow-Origin: *`, and it's `503 { "error": "not_published" }` until the first write. `GET /database` is its page, with when it was written, its size and its license.
- `GET /`, `/setup`, `/guidelines`, `/api`, `/terms`, `/privacy`, `/license` and `/donate` are short pages for people. Any other method on these gets `405`. They, `/stats` and `/database` go out under a Content-Security-Policy that loads nothing from anywhere but this server, lets in only the page's own style block, and keeps any other site from framing them. `/api` is for people building on the list, and shows the public key of `LIST_SIGNING_KEY` as base64 SubjectPublicKeyInfo when the server has one. `/setup` is for someone putting HushSlop on a parent's computer or phone. It links `/download/HushSlop.user.js` and `/download/HushSlop.apk` only while the downloads folder has them, and its pictures are `GET /setup/<name>.png`, a fixed few carried in the server's bundle, as `image/png` with `Cache-Control: public, max-age=3600`. Any other name under `/setup/` is `404`. Every page names `GET /favicon.png` as its tab icon, the 48px app icon from the bundle as `image/png` with `Cache-Control: public, max-age=86400`, and `/favicon.ico` answers with the same PNG for browsers that ask for it on their own.
- `GET /source` is the source of the server that's answering, as the AGPL asks. A server built with `npm run host:build -- --source` serves `hushslop-source-<commit>.tar.gz` from beside its bundle as `application/gzip` with `Content-Disposition: attachment`. It's `git archive` of the full commit hash it was built from, its files under `hushslop-<commit>/`, so it holds that commit's tracked files and nothing else. Without the file it's a `302` to the GitHub repository. Every page's footer links it.
- `GET /stats` is the stats page, like SponsorBlock's: the totals and the top 20 reporters this week and of all time. `GET /v1/stats` is the same as JSON, with `Cache-Control: public, max-age=60` and `Access-Control-Allow-Origin: *`. Both are read at most once a minute. `reports` counts every report ever kept, those on posts that have since aged out too, but not a banned install's. `votes`, `posts` and `installs` count what's held now. `platforms` splits the held votes and posts by site, and `listed` and `lastFlush` say what the last publish put out and when. `week` and `allTime` are the boards, each place a `{ "name", "code", "held", "reports" }`, where a report is one install's report however many keys it carried. A board ranks by `held`, the reports with an AI vote on a post whose counts reached a label (reported, widely reported or disputed) or that the owner ruled AI, and that wasn't ruled real. Ties go by `reports`. A report on a post that has aged out keeps whether it held up when it went. An install shows by the name it picked ([Names](#names)), or else by its `code`, the first 8 hex digits of the SHA-256 of `secondlook-install:<installId>` (the project's first name, kept so a code never changes). The page writes that as `#1a2b3c4d` and never shows an install ID. A banned install isn't on the boards, and neither is one with more strikes than confirms ([Standing](#standing-and-new-installs)).
- `GET /appeal` is a form where a creator asks the owner to look again at a listed video they say is theirs and real (or one whose author, as its reports settle it, is a creator the owner ruled AI, since the apps cover it the same way), and `?link=` fills in the link. It needs no script. Its policy lets in only the page's own style and lets the form post only back to this server. `POST /appeal` takes the form as `application/x-www-form-urlencoded` (`415` for anything else, `413` past 24 KB): a `link`, a `message` of 10 to 1,000 characters, an optional `contact` of up to 200 and `mine=1` when the sender says the account that posted it is theirs. A post from a page on another site gets `403`. A browser that sends `Sec-Fetch-Site` is taken at its word, and for one that doesn't, an `Origin` it sends has to match `Host`. Every answer is a page, with what went wrong in words and the fields as they were typed, and a link that isn't one video, post or account page the server can read gets `400`. A creator the owner ruled AI can ask about that ruling by sending their own page's link as the `link`, one that names the account by its id (a Facebook profile, a YouTube `/channel/` address, an X `/i/user/` address or a Reddit `/user/` page), or their creator key itself ([keys.md](keys.md)). A TikTok page's address carries only a name that can change, so for a `tiktok.com/@<name>` link the server opens that page once and reads the account's number from its data, keeping the answer an hour (ten minutes for a miss) and opening at most 60 pages an hour, 10 of them for one network (`429` with `Retry-After` past either). A request with the trap field filled only gets a kept answer and never has a page opened. A page TikTok doesn't name an account on gets `400`. Instagram shows the number only to someone signed in, so an Instagram page gets `400` saying to send the key, or a post with a label and a message that it's about the account. With `mine=1`, and always for a creator's own request, the thanks page and the `already_sent` page give a code for the account's bio, `SL-` and six letters of Crockford's base32 made from a keyed hash of the network and the post (or the creator), so the same network always gets the same code for it and nobody without the server's key can guess it.
- The queue takes an appeal only for a post on the list or one its reports settle on a creator ruled AI, by the author rule above, so one install naming a ruled creator isn't enough, or for a creator's own key while the owner rules them AI (`404` with `not_listed` otherwise, a creator with no ruling or ruled real included, and `409` with `ruled_real` for a post already ruled real, code 7, where the page says so), and a share link counts only when the queue already followed it. A post holds three open appeals (`409` with `already_open`) and the server 500 (`503`). A network (an IPv4 address or an IPv6 /48) holds one open appeal on a post, counting a share link as the post it leads to, and sending another gets `409` with `already_sent` until the owner closes the first. Opening it again doesn't hold the place again. The network's place is a keyed hash of it and the post, with no day in it, and it's deleted when that appeal closes. One device bucket sends three a day and one network ten, and everyone together 60 an hour. Past any of those it's `429` with `Retry-After`, and when it's the 60 the page says the hour is busy rather than blaming the sender. Only an appeal that's taken counts toward those. Something that fills in the `subject` field, which people never see, gets the answer a person would and counts against the same limits, but only its network's place on the post is kept, for 7 days, so sending it again gets `already_sent` as a person's would. The address isn't kept. An appeal is deleted 30 days after it's closed, or 90 days after it was last opened (when it came in, or when the owner opened it again) if it's still open.
- An appeal with a code doesn't count toward its post's three, isn't turned away when they're full and doesn't take one. It counts toward the 500 and the limits like any other. A network holds one open claim on a post, under a bucket of its own, so its claim and its plain request are kept apart and a request already open from a shared connection never takes the creator's code. An open claim on the post with the same code also answers `already_sent`, so one the owner opened again isn't sent twice. The code a person is shown is on their network's open claim, if there is one. A trapped send's code is kept nowhere. The code is deleted with its appeal.
- The owner gets an alert when 100 appeals are open, when the queue's 500 are full and when everyone's 60 for the hour are used up, which is what junk sent from many networks looks like. It also gets one when a publish fails twice in a row (a `5xx` from the queue), and one for each post the moderation page's sudden reports would show for the last hour, by its key, its AI reports and their networks, leaving out posts the owner already ruled on. Up to 3 spiking posts go out a run, the ones not told about yet first. Each kind, and each spiking post, goes out at most once every 6 hours (10 minutes on when the webhook didn't take the last one), as a line starting `alert:` in the server's log and, when `HUSHSLOP_ALERT_URL` is set, a `POST` to that address. A Discord or Slack webhook gets the JSON it reads (`content` or `text`), and any other address the text as `text/plain`. The address is read like a secret (`HUSHSLOP_ALERT_URL_FILE` works), has to be http or https, and is never printed, so a webhook's key in it stays out of the log. A redirect isn't followed, and a webhook that fails or takes over 10 seconds is logged without the address.

A publish stages every file, then a manifest naming them, in `publish/<id>/` and flushes them to disk. Only then does the queue mark it in flight. The files are renamed into place and the change is logged in `publish/commits.log`. When a restart finds a change marked in flight that isn't in that log, it finishes the renames and logs it, the way a GitHub commit whose answer was lost is found to have landed. A batch that showed even for a moment never goes out again under another name. A change that was never marked is cleared away by the next one, and nothing of it was ever served.

### Running your own server

It runs the same report handling as the Worker on Node 24, with SQLite on disk in place of the Durable Object and a folder in place of the GitHub repo. The rules, limits and reporter buckets are the Worker's own code, so the two count votes identically. It's what runs the public server.

```
npm run host:build   # host/dist/hushslop-host.mjs, one file with nothing else to install
node host/dist/hushslop-host.mjs
npm run host:build -- --source   # the same, plus the commit's tarball for /source
```

`npm run server` is the own server in development mode, on `http://127.0.0.1:8787` with its data in `./data/dev`. It makes its signing key and bucket secret at start when they aren't set, and publishes every 10 seconds. Every build reports to the public server. To try one against `npm run server`, set the server address to `http://127.0.0.1:8787` (the extension asks the browser to allow that address) and clear the list address, since no build carries the key a development server signs with. It counts reporters by network like the public one, so reports from one computer count as one person. The Android emulator reaches your computer at `http://10.0.2.2:8787`, and a phone on USB can use `adb reverse tcp:8787 tcp:8787` with `http://127.0.0.1:8787`.

| Variable | Default | Meaning |
|---|---|---|
| `LIST_SIGNING_KEY` | required (made at start with `npm run server`) | The private key from `npm run keygen` in `worker/`. Every client also carries the next key, so if this one leaks, swap in that one's private half and each install retires the old key at its next download ([Reading the list](#reading-the-list)) |
| `REPORTER_BUCKET_SECRET` | required (made at start with `npm run server`) | At least 32 random characters. Keep it for good, since a new one lets every network count again on posts it already voted on |
| `PORT`, `HOST` | `8787`, `127.0.0.1` | Where it listens |
| `HUSHSLOP_DATA` | `./data/host` (`./data/dev` with `npm run server`) | The votes (`queue.db`), what's published (`public/`) and the publish journal |
| `HUSHSLOP_FLUSH_SECONDS` | `300` (`10` with `npm run server`) | How often waiting votes go out to the list and the logs |
| `HUSHSLOP_TRUST_PROXY` | off | `1` behind Caddy or nginx, so the client is the last `X-Forwarded-For` entry. Only a connection from a private or loopback address counts as the proxy |
| `HUSHSLOP_CONTACT` | none | An email address or link for the terms, privacy and license pages |
| `HUSHSLOP_OWNER` | none | The name the terms page gives as the owner of the reports. Without it the page says they belong to whoever runs the server |
| `HUSHSLOP_ADMIN_TOKEN_HASHES` | none | The sha256 of each admin token in hex, separated by commas. Without it or the next one there's no admin API |
| `HUSHSLOP_VOLUNTEER_TOKEN_HASHES` | none | The same for [volunteers](#volunteers), whose tokens rule posts and creators and answer appeals but can't ban, warn, rename, publish or see who voted |
| `HUSHSLOP_DOWNLOADS` | none | A folder served at `/download/<name>`. Put the files from `dist/download/` there and script managers pick up each new version from `HushSlop.meta.js`. A release build adds `HushSlop.apk`, which the setup page links once it's there. Each name is served under its old one too (`SecondLook.user.js`, `SecondLook.meta.js`, `SecondLook.apk`), and either name reads the `HushSlop.*` file when the folder has one and the `SecondLook.*` file when it doesn't, so installs from before the rename keep updating |
| `HUSHSLOP_ALERT_URL` | none | Where alerts go besides the log: 100 appeals waiting, the appeal queue full, the appeal page's 60 for the hour used up, a publish failing twice in a row, or a post whose AI reports suddenly sped up (named with its count, up to 3 posts a run). A Discord or Slack webhook gets the JSON it reads and any other address (an ntfy topic, say) plain text. Each kind, and each spiking post, goes out at most once every 6 hours, or 10 minutes after a webhook that didn't take it. Alerts come in Spanish when the server's `LC_ALL`, `LC_MESSAGES` or `LANG` (or the system) asks for it, and the start line then says `alerts in es` |
| `HUSHSLOP_OUTSIDE` | none | The [outside lists](#outside-lists) to read, separated by commas: `notes` (X Community Notes) and `aislist` (AiSList's YouTube channels) |
| `HUSHSLOP_OUTSIDE_HOUR` | `6` | The UTC hour, 0 to 23, the outside lists are read again each day |
| `POW_BITS` | off | 1 to 28. Each install has to find a proof of work once before it reports, about 2^bits hashes ([Proof of work](#proof-of-work)). 20 is about half a second on a PC and longer on a phone. The Hush client works it out on a low-priority thread of its own and holds its other reports until it's done, so keep it at 20 or under. A GPU gets through it fast, so it slows a flood of fresh installs without stopping a determined one |

The two secrets, the token hashes and the alert address can each come from a file instead, named by the same variable with `_FILE` on the end. The Worker's `LIMIT_*`, `FLUSH_BATCH`, `NEW_REPORTER_DAYS`, `BRIDGE_DISPUTES` (`1` turns on the bridging rule in [keys.md](keys.md#labels)) and `LIST_MAX_ENTRIES` (250,000 entries at most in each list) settings work here too. It serves the report API, `/list.json` and the compact `/list.bin` for the Hush apps, each with its `.sig`, the logs under `/reports/`, a home, stats, guidelines, API, database, terms, privacy, license and donate page, and the downloads folder when there is one. Only files straight in it ending in `.js`, `.zip`, `.crx`, `.xpi`, `.apk` or `.sha256` are served, and the home page links the userscript once it's there. `/setup` walks someone through putting HushSlop on a parent's computer or phone, with pictures from `docs/screenshots` that the build puts in the server file. It gives the install steps for the userscript and the Android app only when the downloads folder has them. The AGPL asks a server to offer its own source, so every page's footer links `/source`. With `--source` the build writes `host/dist/hushslop-source-<commit>.tar.gz` (a `git archive` of the commit, so an `.env` or key beside it never gets in), and it stops before building if anything isn't committed. Copy that file next to the server file and `/source` hands it out. Without it, `/source` goes to the GitHub repo. Back up the whole data folder. The list is rebuilt from `queue.db`, so `public/` alone isn't enough to bring a server back.

`/stats` works like SponsorBlock's stats page. It shows the totals, each site's share and the last publish, then the top 20 reporters this week and of all time, by the name each picked or a short code. It never shows an install ID. `/v1/stats` is the same thing as JSON.

![The stats page](../docs/screenshots/stats.png)

`/database` is SponsorBlock's database page for HushSlop. The first publish after midnight UTC writes every post with its label, counts and ruling to `database/posts.csv` in the data folder, and `/database/posts.csv` hands it out under the data license. It never holds an install ID, a network code or anything from an appeal.

### Admin API

The owner's routes live under `/admin/`. Each one needs `Authorization: Bearer <token>`, where the token's sha256 in hex is one of `HUSHSLOP_ADMIN_TOKEN_HASHES` (the owner's) or `HUSHSLOP_VOLUNTEER_TOKEN_HASHES` (trusted volunteers'). A server with neither set answers `404` for all of them, and a missing or wrong token gets `401`. Answers are JSON with `Cache-Control: no-store` and no CORS header, so no web page can read them.

A volunteer's token can call `whoami`, `stats`, `post`, `top`, `spikes`, `appeals`, `proofs` and `outside` (but not `outside/run`), rule on posts and creators with `rule`, close and reopen appeals, and show, hide and reopen proof links. Any other route answers it `403 { "error": "forbidden", "detail": "..." }`. Its answers never say who anyone is: `post` gives the votes without their `installId`, `top` has no `installs`, and appeals come without their `contact`. A token in both lists is the owner's. The server logs each ruling a volunteer makes, each appeal it closes or reopens and each proof link it decides with the first 8 hex digits of its token's hash, the route and the key or appeal number, never the note (`volunteer 1a2b3c4d rule real tt:video:7300000000000000001`), so the owner can see whose ruling it was.

Wherever a post goes, give `url` (a link to the post on any platform above), `key` or `hash`. A share link is looked up through the post the queue already followed it to, or opened once the way a report's would be. A creator's link or key gets `400`.

| Route | What it does |
|---|---|
| `GET /admin/whoami` | Whose token it is: `{ "role": "owner" }` or `{ "role": "volunteer" }` |
| `GET /admin/stats` | Votes held and from how many installs and posts, entries listed, log lines waiting, rulings by verdict, bans, warnings not read yet (`warned`), how many installs count twice (`standing.trusted`) and for nothing (`standing.distrusted`), share links followed, open appeals (`appeals`), trapped appeal sends still holding a network's place on a post (`appealsTrapped`), proof links waiting (`proofs`), Community Notes leads waiting (`leads`), the entries each outside list holds (`outside`, by source), and the last publish's time and commit |
| `GET /admin/post?url=` (or `key=`, `hash=`) | The post's key and platform, its votes before (`votes`) and after (`ai`, `notAi`) the network cap and the weights, how many came in the new installs' network (`fromNew`), its list entry (with `bridged` true when the bridging rule lifted it from disputed to reported), ruling and author, the `creators` its reports named (each one's `hash`, `key` when the server holds it, `claims`, and the owner's `verdict` and `note`), the share links that lead to it, and its newest 50 votes with each install ID. `resolved` is false for a share link that couldn't be opened, which is then shown under its own hash |
| `GET /admin/top?hours=24&limit=20` | The posts with the most AI votes cast or changed in the window, with their entries and rulings, and the installs that voted most in it. `hours` is 1 to 720 and `limit` 1 to 100 |
| `GET /admin/spikes?hours=1&limit=20` | Posts whose AI votes in the last `hours` came in at least four times faster than over the week before, with at least three. Each has its AI and real votes in the window, the AI votes from the week before (`earlier`), what that pace would have brought in the window (`expected`), how many networks they came from and how many from the new installs' network (`fromNew`), its `entry` and its ruling's `verdict` (or null). They come most above their pace first. `since` is where the window starts and `hours` its length. `hourly` is the last 48 hours, oldest first, each with its start (`at`), its `votes` and its `ai` votes. `hours` is 1 to 24 and `limit` 1 to 100 |
| `GET /admin/install?id=` (or `code=`, `name=`) | One install's ban, its `warning` not read yet (`note` and `at`, or null), its vote counts, its first and last vote held, the first vote it ever cast (`firstVote`), when its first days end (`newUntil`, null once they have), its `standing` (`confirms`, `strikes` and the `weight` of 0, 1 or 2), the votes a ban holds that lifting it would put back (`held`), the reports counted against its limit this hour, its newest 50 votes, and how the stats page shows it (`name`, `locked` and `code`). A `code` is the 8 hex digits the stats page shows, with or without its `#`, and a `name` is tidied the way `PUT /v1/name` tidies one. When no install has it that's `404`, and when more than one does it's `409` with their `installIds` |
| `POST /admin/rule` | `{ "url": "...", "verdict": "ai" }`, or `"real"`, or `null` to take the ruling away, with an optional private `note` of up to 500 characters. A creator's page or author key rules on the creator. Answers the post's (or creator's) `hash` and `key`, whether the ruling `changed`, and the `entry` it'll be listed with. A share link that can't be opened gets `422`, and so does a hash the server holds nothing under |
| `POST /admin/warn` | `{ "installId": "...", "note": "..." }`. The note is what the reporter reads ([Warnings](#warnings)), so it's needed, at most 300 characters once trimmed. Warning again replaces it. Answers the warning's `at` |
| `POST /admin/unwarn` | `{ "installId": "..." }`. Takes a warning back before it's read. Answers whether one was `lifted` |
| `POST /admin/ban` | `{ "installId": "...", "note": "..." }`. Answers how many votes it `removed` |
| `POST /admin/unban` | `{ "installId": "..." }`. Answers whether a ban was `lifted` and how many votes it `restored` |
| `POST /admin/name` | `{ "installId": "...", "clear": true }` takes the install's name off the stats page, and `"locked": true` keeps the install from changing it (`false` lets it again). Give either or both, and name the install by `code` or `name` in place of `installId` the way `/admin/install` takes them. A locked install with no name shows by its code and can't pick one. Answers the `installId` with its `name`, `locked` and `code` as they are now |
| `GET /admin/appeals?closed=0&limit=50` | Creators' appeals from `/appeal`, the open ones with a bio `code` first, each post's together from its oldest, and then the rest oldest first, or with `closed=1` the last closed first. Each has its `id`, the post's `hash`, `key` and `platform`, the `link`, `message` and `contact` as sent, when it came in (`at`), `closedAt`, the owner's `note`, how many open appeals have exactly the same `message` (`alike`, this one too while it's open), the `code` its sender was given for the account's bio (null when they didn't say it's theirs), the post's list `entry` and ruling `verdict` as they are now, and the `creators` its reports named, as `/admin/post` gives them. An appeal sent with a creator's page or key has the creator's `hash` and `key`, their own `entry` and `verdict`, and that creator alone in `creators`, with `claims` counting the reports that name them on any post. `open` counts every open appeal, `mark` is the count that alerts the owner (100) and `max` the most the queue holds (500). `limit` is 1 to 200 |
| `POST /admin/appeal/close` | `{ "id": 1, "note": "..." }`, with the same private note as a ruling. Add `"allOnPost": true` to close every open appeal on that appeal's post, since a ruling of real answers them all. Or add `"sameMessage": true` to close every open appeal with exactly the same message on any post, which clears a run of junk in one step, and the answer then says `sameMessage: true`. Answers the `id`, `open: false`, whether that one `changed`, and the numbers it `closed`. A number with no appeal gets `404`. An `allOnPost` or `sameMessage` that isn't true or false gets `400`, and so do both at once |
| `POST /admin/appeal/reopen` | `{ "id": 1 }`. Answers the `id`, `open: true` and whether it `changed`. The note stays, and the appeal gets a fresh 90 days before it's deleted |
| `GET /admin/proofs?state=waiting&limit=50` | Links people sent from `/proof` ([Proof links](#proof-links)). `state` is `waiting` (oldest first), `shown` or `hidden` (both last decided first), or `leads` for the Community Notes leads still waiting ([Outside lists](#outside-lists)), with `source: "notes"` on each. Each has its `id`, the post's `hash`, `key` and `platform`, the `side` it's for (`ai` or `real`), the `link` as kept, its `site` (the host without `www.`, or null), `factCheck` (true when the link goes to a well-known fact-checking outlet, which only says where it goes), `others` (how many other posts the same link was sent for), the sender's `note`, when it came in (`at`), `decidedAt`, whether it's `shown`, and the post's list `entry` and ruling `verdict` as they are now. `waiting` counts every waiting link, `mark` is the count that alerts the owner (100) and `max` the most the queue holds (500). `limit` is 1 to 200 |
| `POST /admin/proof/show` | `{ "id": 1 }`. Lists the link on its post's proof page. Answers the `id`, `state: "shown"` and whether it `changed`. A number with no proof link gets `404`. A lead also takes `"side": "ai"` or `"real"`, since the note may link the real original rather than a copy of the fake, and is shown for that side. A link someone sent keeps the side they picked whatever `side` says, and a `side` that's neither gets `400` |
| `POST /admin/proof/hide` | `{ "id": 1 }`. Keeps it off the page, and it's deleted 30 days later. Answers as `show` does, with `state: "hidden"` |
| `POST /admin/proof/reopen` | `{ "id": 1 }`. Sends a shown or hidden link back to wait, off the page, with a fresh 90 days. Answers as `show` does, with `state: "waiting"` |
| `GET /admin/outside` | `sources` holds each outside list's `source`, whether it's `on`, the `entries` it holds, whether a read is `running`, and its last run's `startedAt`, `endedAt`, `counts` and `outcome`: `ok`, `kept the last list: ...` (a read that would have dropped most of it), `lookups stopped: ...` (YouTube turned AiSList's lookups away, so the rest wait for the next run), `failed: ...`, `stopped with the server`, or `cut off when the server stopped` for a run a restart ended. `aislist` also has the handles it found a channel for (`known`) and the ones that are gone (`missing`). `leads` counts the leads waiting |
| `POST /admin/outside/run` | `{ "source": "notes" }` or `"aislist"`. Reads that list now, in the background, and `GET /admin/outside` tells how it went. `202 { "started": true, "source": "notes" }`, or `409 running` when it's being read already. A source the server wasn't started with gets `400`. Owner only |
| `POST /admin/flush` | Publishes what's waiting now and gives the publish's answer |

A route called with the wrong method gets `405`. `top`, `spikes` and `stats` read the whole votes table. That's cheap on a server of your own, and it's why the Worker has no such routes.

The same server serves a moderation page at `/moderate` once it has owner or volunteer token hashes, and `404` until then. It signs in with the token, keeps it for the tab (or the browser, if asked), and calls the routes above. A volunteer's sign-in shows a Volunteer tag and leaves out Publish now, the busiest installs, the install lookup and the contacts. Appeals show there open or closed, and one whose sender says the account is theirs comes first with the code to look for in its bio, next to the other claims on that video. "Rule real and close" rules the post real and closes every open appeal on it in one step. On a creator's own request it's "Rule creator real and close", which does the same for the account, and the card lists that creator with their ruling buttons. Its Undo opens the ones it closed and puts the old ruling back. Once 100 are open a notice says so, and an appeal whose text other open ones share gets a "Close all N with this text" button, whose Undo opens every one it closed. Proof links wait under Proof, waiting, shown or hidden, with Show and Hide on each and an Undo on the toast. Leads from Community Notes have their own view there, with the X post the note is on and the note's text, and Rule AI, Rule real and Dismiss on each. Its policy loads scripts only from its own server, which serves just `/moderate.js`, and styles only from the one block the page carries. The page calls nothing but its own server, a post it opens can't reach back into it, and nothing it shows from the server goes in as markup.

### Owner command line

Whoever runs the server decides what the list says, the way SponsorBlock's creator does for SponsorBlock. `hushslop-admin` is the command line for that:

```
npm run host:build                              # also builds host/dist/hushslop-admin.mjs
node host/dist/hushslop-admin.mjs token       # once: saves a token to ~/.hushslop/admin-token and prints its hash
node host/dist/hushslop-admin.mjs post https://www.tiktok.com/@someone/video/7300000000000000001
node host/dist/hushslop-admin.mjs top --hours 24
node host/dist/hushslop-admin.mjs spikes --hours 3
node host/dist/hushslop-admin.mjs rule ai <link> --note "same clip as last week"
node host/dist/hushslop-admin.mjs warn <installId> "Please report only videos you watched to the end"
node host/dist/hushslop-admin.mjs ban <installId> --note "one phone, forty installs"
node host/dist/hushslop-admin.mjs appeals
node host/dist/hushslop-admin.mjs appeal close 4 --note "checked the original upload"
node host/dist/hushslop-admin.mjs proofs
node host/dist/hushslop-admin.mjs proof show 2
node host/dist/hushslop-admin.mjs proofs leads
node host/dist/hushslop-admin.mjs proof show 3 --side real
node host/dist/hushslop-admin.mjs outside
node host/dist/hushslop-admin.mjs outside run notes
node host/dist/hushslop-admin.mjs name lock "#1a2b3c4d"
```

Give the server the hash as `HUSHSLOP_ADMIN_TOKEN_HASHES`. The token itself never goes on the server, and listing two hashes lets a new token go in before the old one comes out. The CLI talks to `https://hushslop.getparkerai.com` unless `--server` or `HUSHSLOP_ADMIN_SERVER` names another, and it won't send the token over plain http to anything but this machine. A token saved somewhere other than `~/.hushslop/admin-token` is found through `HUSHSLOP_ADMIN_TOKEN_FILE`. One saved before the rename, in `~/.secondlook/admin-token`, still reads when there's none in `~/.hushslop/`, and a setting still named `SECONDLOOK_*` stops the CLI with its new name rather than being passed over. `--json` prints the server's answer as it came. It speaks English or Spanish, whichever `LANG` (or the system) asks for, and the server's refusals come back in the same language. `LANG=es_ES.UTF-8` before the command picks Spanish.

- `post` takes a link, a key or a hash and shows the votes, the label the post gets, any ruling, who voted and the creators the reports named. Give it a creator's page or key to see the creator.
- `top` lists the most reported posts and the installs that voted most.
- `spikes` lists posts reported far faster than over the week before, which is what a pile-on looks like, and counts the last 48 hours.
- `rule ai`, `rule real` and `rule clear` overrule the crowd on one post. The note stays private. Given a creator's page or key they rule on the creator: AI covers every post of theirs in the apps, and real stops their reported posts from labeling them. TikTok and Instagram creators have no page link, so take the key from `post`, which lists the creators a post's reports named.
- `rule --file rulings.csv` applies a whole sheet of rulings, one row each: `ai` or `real`, a link or key, then the link to the public source the ruling rests on, which becomes the note. Every row is checked before any is sent, so one bad row sends nothing, and running the same file twice changes nothing the second time.
- `warn` leaves an install a note of up to 300 characters, and the reporter does see this one. Their next report is turned away with the note and a link to `/guidelines`. Once they tap "I understand" it goes again and counts. `unwarn` takes the note back if they haven't read it yet. A ban still wins over a warning.
- `ban` takes back everything an install voted and quietly drops whatever it sends later. `unban` lifts it and puts those votes back, all but any that would have aged out by then.
- `appeals` lists what creators sent from `/appeal`, the ones who say the account is theirs first and then the oldest (`appeals closed` for the ones you've closed). `appeal close <n>` and `appeal reopen <n>` file one away or bring it back. `appeal close-all <n>` closes every open one on that video (or that creator, for a creator asking about your ruling on them), and `appeal close-same <n>` every open one with the same text.
- `proofs` lists the proof links waiting, oldest first (`proofs shown` and `proofs hidden` for the ones you've decided). `proof show <n>` puts one on its post's proof page, `proof hide <n>` keeps it off, and `proof reopen <n>` sends it back to wait. `proofs leads` lists the Community Notes leads waiting, each with the X post its note is on and what the note says. `proof show <n> --side real` shows a lead as proof the post is real, for a note that links the real original, and `--side ai` as proof it's AI. Showing a lead doesn't rule on its post, so follow it with `rule` when it settles the question. Hiding one dismisses it.
- `outside` shows each outside list, how many entries it holds and how its last read went, and how many leads are waiting. `outside run notes` (or `aislist`) reads one now instead of waiting for its hour.
- `name clear` takes a name off the stats page. `name lock` keeps its install from changing it, and `name unlock` lets it again. `name` and `install` both take an install ID, the `#` code the stats page shows or the name itself. Eight hex digits without the `#` are tried as a code first and then as a name.
- `flush` publishes now instead of waiting for the timer.

Every ruling also grades the installs that voted on that post before you ruled. An install you keep overruling stops counting, and one that keeps agreeing with you counts twice. Votes cast once a ruling is public earn nothing, so nobody can farm trust by copying the log. For its first week a new install shares one network with every other new install, so a batch of fresh installs can't push a post up or name its creator. `install <id>` shows where an install stands. The rules are in [Standing and new installs](#standing-and-new-installs).

The same controls are on the moderation page at `/moderate`, which shows up once either list of token hashes is set. Sign in with the token and you get the counts, the last 48 hours, sudden reports, the most reported posts, the busiest installs, the appeals waiting, the proof links waiting and, once an outside list is on, the leads waiting. Ruling or banning there takes one click, a warning takes its note and a click, and the toast that follows has an Undo.

![The moderation page](../docs/screenshots/moderate.png)

### Volunteers

Someone you trust can help with rulings and appeals, the way SponsorBlock's VIPs do. They run `hushslop-admin token --volunteer` on their own computer and send you the hash it prints, and you add it to `HUSHSLOP_VOLUNTEER_TOKEN_HASHES` and restart. With that token they can look up posts and creators, rule either one AI or real, close or reopen appeals, and show or hide proof links from the CLI or the moderation page. They can't ban, warn, rename or publish, and they never see an install ID or the contact a creator left. Your server's log names every change a volunteer makes by the first 8 digits of their token's hash, so you can tell whose ruling it was. Take the hash out and restart to stop their token working.

### Appeals

A creator whose real video got reported can ask for a second look at `/appeal` on your server. It's a plain form that works without JavaScript, and the home page links it. They paste the link, say how they made the video and can leave a way to reach them. The request waits on the moderation page and in `hushslop-admin appeals`. "Rule real and close" marks the video as checked and real from the next publish, even when its creator is ruled AI, and closes every request on it. Its Undo puts the ruling and the requests back.

Only a video that's on the list can be appealed, or one whose reports settle on a creator you ruled AI (at least two groups naming them), since the apps cover it either way. One you already ruled real can't be, and the form says it's marked as real. A creator you ruled AI can also send their own page's link, or their creator key, to ask about the ruling on the whole account. A TikTok page's address only has a name that can change, so the server opens the page once to read the account's number (60 pages an hour at most). Instagram only shows that number to someone signed in, so an Instagram creator sends their key or a labeled post with a note that it's about the account. That request always gets a bio code, and its card has the creator's ruling buttons and "Rule creator real and close". Each one holds three open requests, only one of them from any one network until you close it, and a connection can send three a day. No IP address is kept with a request. It has a keyed code of its network while it's open, and a claim keeps its bio code until the request is deleted. A request is deleted 30 days after you close it, or after 90 days if it's still open.

A creator who ticks "It's my account" gets a code like `SL-7K3QX9` to put in the bio of the account that posted the video. Their request doesn't wait for one of the three places, so strangers holding them can't keep the creator out, and it's listed first with the code to look for, next to any other claims on that video. Open the post, go to the account and check its bio. Owning the account doesn't make the video real, so the ruling is still yours. Sending again from the same network shows the same code. A network's claim on a video is kept apart from its plain request, so a stranger on the same connection can't end up with the creator's code.

Junk sent from many networks can still pile up. Once 100 requests are waiting you get an alert (`HUSHSLOP_ALERT_URL`), and another when the queue's 500 are full or the hour's 60 are used up. The moderation page closes every request with the same text in one click.

If your proxy adds a `Referrer-Policy`, set it only when the server sent none (`?Referrer-Policy` in Caddy). The form sends `same-origin`, and with `no-referrer` in its place browsers post it with `Origin: null`. The form still works that way, but it leans on `Sec-Fetch-Site` alone to turn away other sites.

### Proof links

When people argue over whether a listed video is AI, someone often has the thing that settles it: the creator's behind-the-scenes clip, the original photo, a fact check, or the AI tool's page it came from. `/proof` is where they send it. It's a plain form like `/appeal`, and the banners on a listed post link to it with `?link=` set to the post, which also lists the proof already checked for that post.

The form takes the post's link, which side the proof is for (AI or real), the link to the proof and a note of up to 300 characters. A proof link has to be http or https with a real host name (no IP address, no user name in it). Parameters that only say who shared it (`utm_*`, `fbclid`, `igsh`, YouTube's `si`, TikTok's share codes and the like) are cut before it's kept, and YouTube's `t` stays. The post has to be on the list, and the proof can't be a link to the post itself.

Nothing someone sends is public until you show it. A shown link goes on the post's proof page with its side and note, opens in a new tab with `rel="nofollow ugc noopener noreferrer"`, and has no name or address on it. Beside it sits an **archived copy** link, `https://web.archive.org/web/<link>`, for when the clip is deleted or the account goes private. The page only writes that address, so nothing asks the archive anything until a reader opens it. Showing proof doesn't rule on the post by itself. When it settles the question, **Show and rule** on `/moderate` shows the link and rules the post the way it says, with the link as the ruling's note (as `rule --file` does), and Undo puts both back. A shown link on a post that isn't ruled that way yet gets a plain **Rule AI** or **Rule real** there instead.

Answers: `200` with a thanks page, `400` for a field that doesn't hold up, `403` from another site, `404 not_listed`, `409 already_sent` (this network already has one waiting on the post), `409 already_known` (the same link was sent for it before), `409 already_open` (five are waiting on it), `413`, `415`, `429` past a limit (`busy` once everyone's hour is used up) and `503` when the queue is full. A connection can send 5 a day, a group of networks 15 and everyone together 120 an hour. No IP address is kept. The day's codes only count sends, and a keyed code of the network for that post stays while the link waits. A hidden link is deleted 30 days after you hide it and an undecided one after 90 days. A shown one stays as long as the post has votes or a ruling. You get an alert once 100 are waiting, when the 500 are full and when the hour's 120 are used up.

### Outside lists

Other people already keep lists of AI posts, and a server can read two of them. Name them in `HUSHSLOP_OUTSIDE` (`notes`, `aislist` or both) and each one is read at start and then once a day at `HUSHSLOP_OUTSIDE_HOUR` UTC, in the background, one read per list at a time. A read a restart cuts off goes again once that day, so a deploy in the middle of one doesn't cost the day. What a list says goes out with the next publish. Its entries never take votes, appeals or proof away from anyone, and a ruling of yours beats them both ways. A read that would leave a list of 100 or more with under half of what it had keeps the last one instead, since that's a broken source, not a change. `list.json` names each list it used under `sources` ([Publishing](#publishing)), and `/license` credits them.

**X Community Notes** ([how X publishes them](https://communitynotes.x.com/guide/en/under-the-hood/download-data)). X posts every note and its status each day at `https://ton.twimg.com/birdwatch-public-data/<yyyy>/<mm>/<dd>/`, and the server takes the newest day of the last three that's out. It streams the zips through and keeps nothing of them but what it lists. A note counts when it calls its post misleading, ticks manipulated media and says the post was made with AI (in English, Spanish, German or French words, and not "isn't AI"), and only while its status is currently rated helpful. Its X post is listed as code 8 ([Labels](keys.md#labels)), under the newest such note's ID. The post's proof page says so and links the post, where X shows the note to anyone (a note's own page needs an X sign-in). Three noted posts of one creator make them often reported the way three reported posts do.

A note often links the same clip on another site, and those links become **leads**. A lead is a TikTok, Instagram, Facebook or YouTube post that a helpful note links, with a link to the X post the note is on and the first 300 characters of what it says, and it waits under `GET /admin/proofs?state=leads`, `proofs leads` and the moderation page's Leads view, TikTok, Instagram and Facebook first and then the newest. Nothing lists a lead's post until someone decides, since the note may link the real original rather than another copy of the fake. On the moderation page Rule AI and Rule real show the lead for that side and rule its post the same way, with that link as the ruling's note, and Dismiss hides it. Undo puts it all back. A shown lead goes on its post's proof page as just that link, named "a Community Note on X", never the note's text. A lead is known by its note, one to each pair of posts (the newest helpful note wins), and a lead read before the link moved off the note's own page gets the post's link at the next read. A post you've ruled gets no lead, a waiting lead whose note is no longer helpful is dropped at the next read, and a lead you've hidden or shown isn't added again. Leads don't count against the proof queue's limits, a waiting one isn't deleted after 90 days the way an undecided proof link is, and a hidden one goes 30 days after you hide it.

**AiSList** ([github.com/Override92/AiSList](https://github.com/Override92/AiSList), CC BY-NC 4.0) is a community blocklist of AI YouTube channels. The server reads its raw list from GitHub. Most of it is `@handles`, so it looks each one up the way youtube.com does when it opens one, at most 40,000 a run and 334 ms apart, never-checked handles first. A handle found is checked again after 90 days, and one that's gone after 30. YouTube answering 429 or 403, or 10 lookups in a row failing, stops a run early, and the rest wait for the next one. Each channel is listed as code 9 under its author key (`yt:author:UC...`), unless you've ruled on it or its own reported posts make it code 4. The license's NonCommercial term covers what HushSlop does with the list. Anyone who sells a product built on a server's list needs to drop `aislist` or ask the list's maintainers.

## Clients

| Part | Where | What it does |
|---|---|---|
| Browser extension | `extension/` | Chrome and Firefox. Adds the button and the banners to tiktok.com, instagram.com, facebook.com, youtube.com, x.com and www.reddit.com, and can hide a post behind a banner once it has as many reports as you pick. Over TikTok, Instagram and YouTube videos, a video on its own Facebook reel or video page, and every video on Facebook's and YouTube's phone sites (m.facebook.com and m.youtube.com), the bar floats on the picture and a hidden one is covered and paused. Reported thumbnails in TikTok's Explore grid, Instagram profile grids and YouTube's side list and search results get a count chip in the corner. |
| Userscript | `extension/src/userscript.ts` | The same thing for Tampermonkey or Violentmonkey, built from the same code. |
| Android app | `android/` | Apps like TikTok and Instagram can't be changed from outside, so this one lives in the share sheet. Share a video or post from TikTok, Instagram, Facebook, YouTube, X or Reddit to HushSlop to see its reports or add yours. |
| Hush client | `android/hush-client/` | Plain Java that the Hush apps copy in (Hushfeed for TikTok, HushGram for Instagram, Hushfacebook). It keeps the list, sends reports after an Undo window and decides which videos the "AI videos" settings hide. `gradlew :hush-client:test` in `android/` runs its tests. |
| iPhone app | `ios/` | The same share-sheet idea as a Share Extension, plus a small app for pasting links. |
| Report Worker | `worker/` | A Cloudflare Worker that queues reports and every 15 minutes commits them, with a signed list, to a GitHub data repo. Not deployed. |
| Own server | `host/` | The Worker's report code on plain Node, publishing the signed list and the logs from its own folder, with an admin API and a command line for its owner. It's what runs the public server. |
| Shared rules | `spec/`, `packages/core/` | How a TikTok, Instagram, Facebook, YouTube, X or Reddit link becomes a key, how keys are hashed, and when a post gets a label. Every client is tested against `spec/test-vectors.json`. |

### The extension popup

The extension popup opens with what HushSlop did for you: how many posts it flagged this month, and how many of the posts you reported carry a label now. Both are counted on your computer from the list and your own reports, and nothing about them goes to the server. It also shows how many reported posts it knows about and switches between banners and hiding. **Hide after** picks how many AI reports hide a post: 1, 2, 3, 5 or 10, the same choice the Hush apps have, starting at 2. Next to each one is how many posts it would hide right now. A post the list's owner checked hides at any of them, and one you said looks real doesn't hide by its count. The checkbox under that does the same for posts the site itself labels as AI-made, and unchecking it leaves those labels to the site. The server and list addresses are set there too. Every app has both addresses. Reports go to the server, and the list of reported posts comes from the list address, which starts as the public server's `list.bin`, the compact form of its list. Each list has to come with a signature that matches the key compiled into every build. Clear the list address and the app reads the server's own list without checking it, which is what a local server needs. The popup and both apps' Settings also take the name you'd like on the server's stats page. Leave it empty and you show there as a short code nobody can trace back to you.

A site's own AI label shows as "TikTok labeled this video as AI-made" (or the site's name), so nobody had to report it. The extension reads these on TikTok, Instagram, Facebook and YouTube straight from the page and hides them in hide mode too. When Instagram marks an account as AI instead, the banner says so about the account. A post the list's owner checked is hidden in hide mode and in every Hush app, at any threshold, and so is every post of a creator the owner checked.

### The userscript

It updates itself from `https://hushslop.getparkerai.com/download/` once a release is there. The manager's menu has **HushSlop: set server address**, **HushSlop: set list address**, **HushSlop: your name on the stats page**, a switch between banners and hiding, **HushSlop: hide after how many reports**, a switch for the sites' own AI labels, and **HushSlop: what it did for you**, which shows the popup's two counts.

### The Android and iPhone apps

Share any TikTok, Instagram, Facebook, YouTube, X or Reddit post to HushSlop. Settings takes another server's address.

![A shared post with three reports](../docs/screenshots/android-result-3-reports.png) ![After reporting, with Undo](../docs/screenshots/android-result-after-report.png)

Both apps' Settings have **Warn after**, the extension's 1, 2, 3, 5 or 10 choice, starting at 2. The share sheet can't hide anything, so a post with that many AI reports (and more of them than Looks real votes) gets a highlighted card with its count, even before the list labels it. Next to each choice is how many posts in the list it would warn about.

Both apps' home screens have the extension popup's **What HushSlop did for you** card: how many posts you checked this month got a highlighted card, and how many of the posts you reported carry a label now, with Support HushSlop under them. The phone works both out from the list it keeps and your own reports, counting from the first version that has the card.

### Languages

Everything a reporter or creator sees comes in English and Spanish. The extension, the userscript and both apps follow the browser's or phone's language. The server's pages go by the browser too, the moderation page included, and `?lang=es` or `?lang=en` picks one. `hushslop-admin` and the owner's alerts go by `LC_ALL`, `LC_MESSAGES` or `LANG`, and by the system's language when none of them is set.

## Reading the list

Every client keeps one copy of the list and reads it by the same rules: `packages/core/src/list.ts` for the extension and userscript, `ListClient` on Android and iOS, and `PublishedList` in the Hush client. The `lists`, `listSignatures`, `listKey` and `keyRotation` cases in `test-vectors.json` pin them, `compactLists` pins `list.bin`, which every client reads too, and `compactChanges` pins the changes since a version.

A client has two addresses, and a new install starts with `https://hushslop.getparkerai.com` and its `/list.bin`, the Hush apps included. The old default, `/list.json`, reads as `/list.bin`, whether an install saved it or someone types it in. A server or list address saved on `https://secondlook.getparkerai.com` reads as the same address on `https://hushslop.getparkerai.com` in the extension, the userscript and the Android app. The iPhone app and the Hush apps never shipped under the old name, so they have nothing to move. Every client reads either form from either address and tells them apart by the head, and it keeps the codes and counts as they came, code 0 included, since each app applies its own threshold. In the Android and iPhone apps, and in the extension for the old default, a list kept from a `list.json` stands in for the `list.bin` beside it until that downloads, since every publish writes both. Its `ETag` isn't sent there. Reports go to the server address. The list comes from the list address, and when that's empty, from the server's own `/v1/list` without a signature, which is how `npm run server` stands in for the public one in tests.

With a list address the client fetches it, then the same address with `.sig` on the end, and uses the list only when the signature holds for the bytes exactly as they arrived. The keys are compiled in as `LIST_PUBLIC_KEYS`, each the base64 SubjectPublicKeyInfo that `npm run keygen` prints, oldest first. There are two, the key in use and the next one, which stays offline until it's needed. `packages/core/test/clients.test.ts` checks that the Android, iOS and Hush clients carry the same ones in the same order. A build without a key won't use a list address at all, since it can't tell a real list from a forged one.

A list signed by a later key retires every key before it, and that's for good. The client keeps the retired keys (the extension in its storage, the apps in a file the system won't clear) and never takes a list one of them signed again. So if the key in use leaks, the owner sets `LIST_SIGNING_KEY` to the next key, the server puts the list out again at its next run, and every client that downloads it stops trusting the old key without a new build. An older key never retires a newer one. A client that can't save the retired keys doesn't take the list, and once they're saved, no list a retired key may have signed is kept, even when writing the new one fails. Retired keys it saved but can't read back count as every key it carries but the newest, since only a later key's list saves them. The build after that carries the next key and a new one behind it, and a retired key it no longer carries stays retired. The `keyRotation` cases in `test-vectors.json` pin which keys a list leaves retired, and which lists are refused.

Reading a body:

- No `format` means format 1. A format other than 1 or 2, a `version` that isn't a whole number from 0 up, or `entries` that isn't an object, and the client refuses the whole list.
- Format 1 entries are `[code, count]` and format 2's are `[code, ai, notAi]`. Clients read the first two places either way.
- An entry is skipped when its hash isn't 16 lowercase hex characters, its code isn't 1 to 6, or its count isn't a whole number from 0 up. `true` and `"1"` aren't numbers. Code 0 means a post was reported but sits under every label, so nothing shows for it.
- A whole number is read the way `JSON.parse` reads it, so `2.0` and `2e0` are 2 and `1.5` isn't whole.

Keeping it fresh:

- The kept list belongs to the address it came from, and to whether its signature was checked. A new address, or a list address that's the same as the server's `/v1/list`, starts from nothing. A build without a key never serves a list kept from a list address, even one an earlier build checked.
- Updates take turns. A check that arrives while one is under way waits for it and reads what it stored, so a slower download can't put back an older copy.
- A kept list is good for 30 minutes. After that the client asks again with `If-None-Match`, and a `304` keeps the copy it has. A signed `list.bin` asks for the changes since it ([below](#changes-since-a-version)).
- A list with a lower `version` than the kept one is a CDN copy that's behind, and doesn't replace it, unless the kept one's `version` is more than a day (86,400 seconds) past the client's own clock. A list built while the server's clock ran ahead, or signed with a leaked key and a huge version, would otherwise hold every client until real time caught up. The lower list still needs its signature, and once it's kept the usual rule applies again. A list that retires a key replaces the kept one whatever its version, since the retired key may have signed that one. The `listVersions` cases in `test-vectors.json` pin it.
- When an update fails, the kept list still answers, and the next try waits 5 minutes. With nothing kept, the phone apps try again on every check, because each one is a person waiting on an answer. The extension waits either way, since it looks up every post on the page. The Hush client waits too, and doubles the wait after each failure in a row up to 4 hours, because a list that keeps failing its signature costs a whole download every time.
- A report's answer or a failed update that comes back after the address changed never shows against the new address.
- The copy on disk is only a cache. When it can't be written, the check still answers from the list it just downloaded. The note that says which address a kept list came from is removed before the list is written and written again only once the list is, so it never describes some other list.
- A report's answer shows at once. For the keys it reported, the client uses the answer's entries over the list's until a list with a `version` at or after the report arrives, and a reported key the answer leaves out shows no label in that time. Undo hands those keys back to the list.

## Changes since a version

A busy server puts out a new list every 5 minutes, and at the 250,000 entry cap `list.bin` is about 3.3 MB, so a client that downloaded it whole every half hour would pull most of it again for a few changed posts. A client that keeps a `list.bin` asks for `list.bin?since=<version>` with the version it keeps. A server that kept the changes since then sends them, usually a few kilobytes. Any other address ignores the query and sends the whole list, which is what GitHub, jsDelivr and the Worker do, so a client never needs to know which kind it asked.

The changes:

| Bytes | Holds |
|---|---|
| 0 to 3 | `SLD1` in ASCII |
| 4 to 11 | the version they start from, unsigned 64-bit big-endian |
| 12 to 19 | the version they make, the same way |
| 20 to 23 | how many records change or come in, unsigned 32-bit big-endian |
| 24 to 27 | how many hashes go, the same way |
| then | the records that change or come in, 13 bytes each as in `list.bin`, in ascending hash order |
| then | the hashes that go, 8 bytes each, in ascending hash order |
| the rest | the new `list.bin`'s `.sig` file as it's served |

Applied to the kept list they make the new `list.bin` byte for byte. A record that changes takes the kept one's place, one that comes in goes in at its hash, a hash that goes leaves with its record, and the head takes the new version and count. A hash that goes and isn't in the kept list is nothing to do, since two publishes merged can bring a post in and take it out again. A client refuses the whole file when its head isn't `SLD1`, when its length isn't the head plus what its counts name plus a signature, when the signature is missing or past 1,024 bytes, when the version it makes is past 9,007,199,254,740,991, when a record or a hash isn't above the one before it, when a hash both changes and goes, or when it doesn't start from the kept list's version. The signature then has to hold for the list they made, with the same keys and retired keys as a downloaded list's. The `compactChanges` cases in `test-vectors.json` pin all of it.

A client asks for them only when:

- the list is signed, so the server's own `/v1/list` never does,
- the kept list came with an `ETag`, which it sends as `If-None-Match` as usual, so a `304` still keeps the copy it has,
- and the bytes it kept are that address's own `list.bin` of that version, exactly as it came. A `list.json` kept beside it, or one the extension packed into `list.bin` form, never asks.

An answer that starts with `SLD1` is applied and checked, and the list it makes is kept with the answer's `ETag`, with no `.sig` download. Changes that don't apply, or a list whose signature doesn't hold, cost a whole download. The client fetches the list again with no `since` and no `If-None-Match`, then its `.sig`, as if nothing were kept. An answer that's a list is read as one. The `.sig` address is always the list's address with `.sig` on the end, never with the query.

The own server keeps each publish's step from the `list.bin` it served before, for a day, in its database, so a restart keeps them. A `since` it has steps from gets them merged into one file, under the list's own `ETag` and after the `If-None-Match` check, so a client that's up to date still gets a `304`. It sends the whole list instead for a version its steps don't start from, a `since` that isn't a whole number of up to 16 digits, or changes as big as the list. A publish whose list doesn't follow the one served before it (a lower version, a list the server wasn't serving, or changes as big as the list) starts the steps over. A cache in front of the server has to keep `?since=` addresses apart by their query string, as most do by default.

## Building and testing

`npm run release` needs JDK 17 in `JAVA_HOME` and `android/keystore.properties`. `node tools/release.mjs --check` only checks that every file carries the same version. Neither one tags or publishes anything, and neither does `npm run store`. It wants a clean tree because the source zip comes from the last commit. `npm run store -- --check` also unpacks that zip in a temp folder, runs `npm ci` and the build there, and fails unless both extension zips come out byte for byte the same. `npm run icons` scales `assets/icons` again from `assets/icon.png`, and `tools/app-icons.py` redraws the Android and iPhone icons from the same file.

The smoke test serves pages shaped like all six sites from `extension/test/fixtures/`, starts the server in memory, and clicks through reporting, undo, the popup and hiding. It refreshes the screenshots in `docs/screenshots/` as it goes. It also lays out every page, the popup and the bars as 200% browser zoom would, and fails on text that's cut off or drawn over other text. The Firefox pass needs Playwright's Firefox (`npx playwright install firefox`) and loads `dist/firefox` as a temporary add-on. Another Firefox pass installs the userscript in Tampermonkey and then in Violentmonkey, through each one's own install page. The first run downloads both from addons.mozilla.org at the versions pinned in `tests/smoke/userscript-firefox.smoke.mjs` and keeps them in the temp folder. `npm run smoke:android` reports on the live m.youtube.com and m.facebook.com in Firefox for Android on the device `HUSHSLOP_ADB_SERIAL` names.

Firefox for Android 142 or later runs the same `dist/firefox` build on m.youtube.com and m.facebook.com. With the phone on USB debugging and **Remote debugging via USB** on in Firefox's settings, `npx web-ext run -t firefox-android -s dist/firefox --adb-device <serial> --firefox-apk org.mozilla.firefox` installs it until Firefox closes.

The iOS code is checked by a GitHub Actions workflow on macOS (`.github/workflows/ios.yml`). It runs the Swift tests and builds the app for the simulator, then walks the app at the largest text size against a seeded test server (`tests/smoke/seeded-host.mjs`) and fails on text that runs off the screen or lands on other text. Start it by hand with the screenshots box ticked (`gh workflow run ios.yml -f screenshots=true`) and the run prints a screenshot of every step into its log as base64 JPEG between `SHOT-BEGIN` and `SHOT-END` lines. A failing run prints them too.
