/api/reactions — the backend contract for milestone reactions
/api/reactions — the backend contract for milestone reactions
Status: not implemented. This is the contract a backend must satisfy.
The client in assets/js/milestone-detail.js is complete and ships working.
What it does not ship is a place to put reactions: this repository is published
by GitHub Pages, which serves static files and answers nothing but them. So
reactions are written to localStorage first and mirrored to /api/reactions
when — and only when — that endpoint answers.
Until one exists, the card tells the reader so in plain words rather than implying their reaction was seen by anyone:
Free to post, signed in or not. Kept in this browser only — no shared backend yet.
That line is not decorative. It is driven by the outcome of the first write, and the check that it flips to “shared” is an e2e test. Do not make the UI claim sharing without the server actually returning a canonical reply.
Why local-first rather than server-first
- Posting stays free and instant on a static host. A reaction is a one-click gesture; making it wait on a network round-trip to a service that does not exist would make the feature feel broken rather than provisional.
- Anonymous posting has to be genuinely anonymous. Anonymous votes are
derived from a random id in
localStoragethat cannot be resolved to a person. Any server-side identity for an anonymous reader would weaken that, so the server is told the id it is given and nothing more. - One merge rule, not two. The client merges a server reply by
Math.maxper emoji, never by addition. A server that returns absolute counts can therefore never be double-counted by a client that had already seen some.
The contract
Same-origin. credentials: 'same-origin', so a session cookie set by
auth-bar.js’s GitHub OAuth is sent along automatically — but nothing in the
client depends on it.
POST /api/reactions
Request body:
{
// Always present. `id` is "gh:<session_id>" for a signed-in reader and
// "anon:<local id>" for everyone else. `kind` is "github" | "anonymous".
"identity": { "id": "anon:7f3c…", "kind": "anonymous", "label": "Anonymous" },
// Exactly one of the two below is normally sent.
"milestone_id": "ms-4ff7841d61d0",
// A reaction toggle. `reacted: true` adds, false removes. The emoji must be one
// of the twelve in REACTION_EMOJI; the server should reject anything else
// rather than storing it, because the client will never render it.
"reaction": { "emoji": "🚀", "reacted": true },
// A comment. Already trimmed, control-stripped and truncated to 280 characters
// by the client; the server must enforce the limit itself.
"comment": { "text": "The primary source is a preprint." }
}
Success — 200 with the canonical state of that milestone:
{
"milestone_id": "ms-4ff7841d61d0",
"counts": { "🚀": 4, "🔥": 2 },
"mine": { "🚀": true },
"comments": [
{ "id": "anon:7f3c:lqz3k", "owner": "anon:7f3c",
"author": "Anonymous", "kind": "anonymous",
"text": "The primary source is a preprint.", "ts": 1767225600000 }
]
}
Rules the server owns:
| Rule | Why it is the server’s job |
|---|---|
counts are absolute, keyed by emoji |
The client unions by max; only the server can be authoritative. |
mine reflects this identity |
Only the server knows whether a vote was already counted. The client’s own flag is local optimism. |
One reaction per (identity, milestone, emoji) |
Otherwise a reader can inflate a tally by posting twice. This is the whole point of storing mine. |
comments[].text ≤ 280 chars, author ≤ 64 chars |
The client truncates its own copy; it does not get to decide for other clients. |
comments[].id is stable and unique |
The client de-duplicates by it, so a re-send cannot double a row. |
Unknown emoji and unknown milestone_id are dropped, not stored |
A payload must not be able to grow an unbounded key space. |
| Rate-limit per identity and per IP | The client’s 15 s cooldown is an honesty measure, not a control. |
Any non-2xx response is treated as “there is no backend here” and the client
falls back to local-only. That is deliberate: a 500 must not lose the reaction the
reader just made, and it must not leave the composer stuck in a pending state.
Return 2xx only when the write actually persisted.
GET /api/reactions?milestone_id=<id>
Returns the same shape. The client calls this at most once per milestone, and only after a write has proved the endpoint is live — so a static host is never charged for a request it cannot answer.
Verifying an implementation
The e2e suite already exercises both sides of this contract against a stub:
bunx playwright test e2e/milestone-detail.spec.mjs— the default stub answers200with an empty canonical reply, and one block stands in for a missing backend with404and asserts the composer falls back to local-only wording.test/milestone-detail.test.mjs— the merge arithmetic the client applies to your reply (Math.maxper emoji, never addition).
To point the real thing at a live endpoint, set REMOTE_ENDPOINT in
assets/js/milestone-detail.js and delete the page.route stub in the spec’s
beforeEach. The REMOTE_ENDPOINT assertion in the unit suite will tell you if
you changed one and not the other.
Privacy
Deliberately collected, and nothing else:
- the reaction emoji and the milestone id;
- for a comment: the text, a display label, and a timestamp;
- for identity: an opaque id and whether it came from GitHub.
No email, no IP logging beyond what the host does by default, no fingerprinting,
no third-party request. If the reader is signed in through auth-bar.js, the
GitHub login is visible on their own comments — that is the trade for attribution
and it is opt-in by signing in. The client’s identity resolution, including the
rule that an expired session falls back to anonymous, is in resolveIdentity and
covered by test/milestone-detail.test.mjs.