Comments platform

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.tsmode, 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 bannotify_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 when COMMENTS_TELEGRAM_DIRECT_REPLY is 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 held outcome can quietly become published a 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:

  1. Add the ops bot to the discussion group as admin, Delete messages only — see above.
  2. Set TELEGRAM_DISCUSSION_CHAT_ID from getChat(@tutumood).linked_chat_id.
  3. Set COMMENTS_OWNER_TELEGRAM_USERNAME to the owner’s Telegram @handle.
  4. Allow the mood_comment_create Turnstile action on the widget (Cloudflare dashboard) — separate from blog_comment_create, so the two surfaces can be tuned apart.
  5. Ship with MOOD_COMMENTS_ENABLED=false first, verify the bot receives group messages and the mapping backfills for existing posts, then flip it to true.

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.