Docs Platform
Comments platform
What the comments feature needs configured, what runs on a schedule, and the two independent ways to stop somebody.
The reader’s view of the comment box is Comments and the wire contract is Blog Comments API. This page is the operator’s half: the switches, the bindings, the cron work, and the moderation levers.
Almost all of it lives in site-api. The public site Worker renders the
section and owns /reader/confirm; it stores nothing and holds none of these
secrets.
The kill switch
COMMENTS_ENABLED gates every comment, reader, reaction, and OAuth route in
site-api. Anything but the exact string "true" is off, and off means a flat
404 from all of them — not a friendly “comments are disabled” envelope, on
purpose: an off feature should look absent, not broken-with-details.
It is currently "false" in production.
The switch is one-sided. site decides whether to render the section from
blog.comments
and the post’s own tags, and knows nothing about the API’s flag, so turning the
API off leaves a rendered box that answers 404 — which the client reads as
GONE and shows as “comments aren’t available right now”. Adequate as a
degraded state, and not something to leave standing: turn the section off in
site too if the switch is going to stay off.
Configuration
Every one of these is read from site-api’s env.
| Variable | Purpose |
|---|---|
COMMENTS_ENABLED |
The kill switch above. "true" or nothing |
COMMENTS_MODE |
Site-wide default policy mode; a post’s tags fold on top |
COMMENTS_REACTIONS |
"false" turns hearts off everywhere |
COMMENTS_REQUIRE_VERIFIED_EMAIL |
"true" makes verification the site-wide floor |
COMMENTS_OWNER_EMAIL_HASH |
sha256(normalizeEmail(ownerEmail)). Drives the author badge by equality against a row’s email_hash. Unset means no badge, never a false one |
COMMENTS_OWNER_DISPLAY_NAME |
The name the owner’s replies post under |
COMMENTS_TELEGRAM_DIRECT_REPLY |
"true" lets the ops bot post a reply straight from Telegram |
COMMENTS_SESSION_SECRET |
HMAC key behind reader sessions, the anonymous session id, ip_hash and fp_hash. Missing logs one warning and disables sessions rather than throwing |
COMMENTS_EMAIL_SECRET |
Signs verification, mute, and delete tokens |
COMMENTS_GHOST_FETCH_TIMEOUT_MS |
Ceiling on the post-registry lookup |
AKISMET_API_KEY |
Moderation. Absent means every comment falls through to the fail-closed path |
AKISMET_TEST_MODE |
"1" marks every check as a test, so staging and the e2e matrix never train the real classifier |
Reader OAuth needs four more — GITHUB_READER_OAUTH_CLIENT_ID/_SECRET and
GOOGLE_READER_OAUTH_CLIENT_ID/_SECRET — and none of them is required,
because nothing on the site links to /oauth/reader/:provider. Unset, the
route answers a clean 404 and every reader stays L1. They live in
DORMANT_SECRETS in site-api’s readiness script rather than
REQUIRED_SECRETS, so shipping comments does not mean registering two OAuth
apps nobody can reach; move them back the day a sign-in button ships. The
reader pair must never share credentials with the admin GITHUB_OAUTH_*
pair — that app is allow-listed to one human, this one would be open to
anyone.
The three policy defaults have a twin in site’s src/data/site.ts —
mode, reactions, requireVerifiedEmail. The per-post half cannot drift
(both halves read the Ghost tags through one function in
@bunizao/contracts), but those three lines and these three variables have to
be changed together.
COMMENTS_MODE=off and COMMENTS_ENABLED=false are not the same lever: the
first is a policy answering “this post takes no new comments” with a 403 and
a readable thread; the second makes the whole feature disappear.
Bindings
| Binding | Used for |
|---|---|
NOTIFY_DB (D1) |
blog_comments, blog_reactions, notify_subscribers, mutes |
RATE_LIMITER (Durable Object) |
Every comment and reaction budget. Durable, not observability mode — this is the only route family on the site that is |
CACHE / SESSION (KV) |
Shadow-ban keys. Absent fails open, meaning nobody is shadow-banned |
BLOG_IMAGES (R2) |
Cached reader avatars, keyed by email hash |
Scheduled work
All of it runs from /notify/schedule, which fires on the site’s
15-minute cron (every scheduled trigger except the hourly mood-stats one).
Each job is a bounded, idempotent sweep, so running it ninety-six times a day
costs nothing beyond the four statements it issues.
| Job | What it removes |
|---|---|
| Unverified address sweep | An address that never confirmed, 7 days on |
| Comment risk signals | ip_hash, fp_hash, ua, country, asn, nulled in place 90 days after the comment was written. The comment itself stays |
| Expired email-change requests | Tokens nobody used |
| Expired delete requests | Same |
The 90-day sweep is the retention promise behind the privacy map. The risk signals exist to catch a wave of abuse as it happens; three months later they are not evidence of anything, they are just a per-comment record of where somebody was sitting.
Stopping somebody
Two mechanisms, deliberately independent, because they answer different questions.
Shadow ban — a KV key under comments:shadowban:, matched on email hash,
IP hash, or fingerprint hash. A listed writer’s comment is created and held,
and they are never told: their own browser shows the normal “sent for review”
state, and nobody else ever sees the row. There is no admin-portal write path;
the list is managed with wrangler kv key put, keyed individually so a lookup
never fetches a growing blob. Missing KV fails open.
Reader ban — notify_subscribers.banned on the reader row. This one is not
quiet. A banned reader’s session is refused on sight, so it takes effect on the
next request rather than at the next cookie expiry; their hearts drop out of
reaction counts and reactor lists; and reply mail stops. It is the lever for an
identity that should lose its account, where a shadow ban is the lever for a
source that should stop being productive without learning why.
Neither retroactively deletes anything. Both leave existing published rows standing — removing those is a moderation action of its own.
Moderation surfaces
- Telegram ops bot at
/webhooks/telegram-ops— the notification for a new or held comment, with the decision keyboard attached, plus direct reply whenCOMMENTS_TELEGRAM_DIRECT_REPLYis on. Separate path, separate secret, and an operator-id allowlist; see Internal routes. - Admin portal — the comment routes under
/admin, listed in the same place. - Akismet — every submission is checked; ham publishes, spam holds, and
the “blatant” signal rejects. Any error, timeout, or unparseable answer
holds. The create request waits 1500ms for the verdict and finishes the
check in the background if it runs over, so a
heldoutcome can quietly becomepublisheda second later.
Mood surface
surface: 'mood' shares every switch, table, and moderation lever above with
the blog — same COMMENTS_ENABLED, same blog_comments table, same risk
stack, same Telegram ops bot. What it adds is the bridge into the post’s
Telegram discussion group, gated by its own kill switch, and detailed end to
end in Comments API § Mood surface
and Telegram pipeline § The comment bridge.
MOOD_COMMENTS_ENABLED is the mood-specific kill switch, independent of
COMMENTS_ENABLED above: "false" (the default) forces every mood
document’s discussionLinked to false — the compose box never renders,
/mood/[id] keeps the “Leave a comment on Telegram” link — and a
surface: 'mood' create answers 404, same as an unlinked post. It also
stops every Telegram call the bridge makes — sends, edits, deletes, both
hourly sweeps, and the reply notification for group replies — so flipping
it off mid-incident silences the bot at once. Reads are unaffected either
way: the plain Telegram scrape keeps working, rows already bridged still
overlay, and the discussion-thread mapping keeps filling in from automatic
forwards, so turning it back on needs no backfill.
| Variable | Purpose |
|---|---|
MOOD_COMMENTS_ENABLED |
The mood kill switch above |
TELEGRAM_DISCUSSION_CHAT_ID |
The discussion group’s chat id — from getChat(@tutumood).linked_chat_id, printed by scripts/print-discussion-chat.ts in site-api |
COMMENTS_OWNER_TELEGRAM_USERNAME |
Marks the owner’s own group replies byAuthor on the web, the mood equivalent of COMMENTS_OWNER_EMAIL_HASH |
The ops bot must be a member of the discussion group as an admin, with only Delete messages granted — nothing else. Admin is required for Telegram to deliver it group messages at all (bot privacy mode otherwise hides them); Delete messages is the one permission the bridge actually uses, to retract a comment the owner hides or deletes on the site side.
Phase 0 checklist
One-time setup before flipping the switch, in order:
- Add the ops bot to the discussion group as admin, Delete messages only — see above.
- Set
TELEGRAM_DISCUSSION_CHAT_IDfromgetChat(@tutumood).linked_chat_id. - Set
COMMENTS_OWNER_TELEGRAM_USERNAMEto the owner’s Telegram@handle. - Allow the
mood_comment_createTurnstile action on the widget (Cloudflare dashboard) — separate fromblog_comment_create, so the two surfaces can be tuned apart. - Ship with
MOOD_COMMENTS_ENABLED=falsefirst, verify the bot receives group messages and the mapping backfills for existing posts, then flip it totrue.
check-production-readiness.ts (site-api) adds TELEGRAM_DISCUSSION_CHAT_ID
to the required-secrets set once MOOD_COMMENTS_ENABLED=true — the readiness
check fails loudly rather than the bridge silently never sending.