Skip to content

Moderation

Nothing a stranger writes appears on your site until you say so. /admin on your Worker’s address is where you say so.

It is a React application the Worker serves itself, so loading the dashboard costs one request rather than three.

One password, the one you set as CHARCHA_DASHBOARD_PASSWORD. There is no user table, no account to create, no reset link and no second moderator.

With that secret unset, the dashboard turns everyone away, including you. A wrong password and an unconfigured deployment look identical from the login screen, on purpose. Telling them apart on the login screen would tell a stranger whether the door is even there. The Worker’s log distinguishes them: look for guard: dashboard-password. It is written once when a fresh copy of your Worker starts up rather than on every request, so not finding it is not proof the password is set. Deploy again if you need to see it, and read Health and monitoring for why the line behaves that way.

Four things happen to a login attempt, in this order. An origin check, the throttle, a look at the body, then the comparison. The throttle allows ten attempts a minute per client and runs before any hashing, so somebody guessing cannot make your Worker do cryptographic work on every try. The comparison itself is constant time over two digests.

Twelve hours, and it does not renew.

There is no session table anywhere. The cookie holds a signed expiry and that is the whole of it, which means an authenticated request costs you no extra database work and signing in costs no write at all.

That cookie is __Secure-charcha_session, marked HttpOnly, Secure and SameSite=Strict, and scoped to Path=/admin. The path is doing more work than it looks. It is what keeps the promise that Charcha writes nothing to a reader’s browser: your own browser never attaches this cookie to /comments, so it never rides along with a request one of your readers makes.

Signing everyone out means changing CHARCHA_DASHBOARD_PASSWORD. There is no per-session revocation, because the signing key is derived from the password rather than stored, so a new password invalidates every session at once.

When a session expires the queue unmounts and the sign-in form comes back with one sentence different from the usual one. An interrupted triage should not look like a finished one.

Nothing under /admin sends CORS headers, and there is no preflight route. A moderation queue has no legitimate cross-origin caller.

Four tabs: Pending, Spam, Approved, Setup. Each queue tab carries a count, and those counts are the database’s answer rather than the browser adding up what it can see. They are re-read after every decision.

You get 50 comments at a time, newest first, with a cursor for the next page. Ask for more than 200 and you get 200 back rather than an error, because failing loudly there would empty the screen of the one person doing the moderating.

A tab that has not loaded yet shows no count. A tab whose queue is empty shows 0. Those are different states and they look different.

The commenter’s name. The comment body, rendered from Markdown. The page it was left on, by title if the thread has one and by its key otherwise. Its age, and the exact timestamp behind that. And when a spam layer is the reason it is sitting there, the short token that layer recorded.

Not the commenter’s email address. Charcha stores it and never sends it to this screen.

The row itself takes focus rather than a link inside it, so a screen reader reads the whole row that J and K land on. The three buttons in a row are reachable with Tab and do the same three things the keys do.

Section titled “Your site’s address, and the link on each card”

Once you have saved your site’s home page address on the Setup tab, the page name on every card becomes a link to the page itself, opening in a new tab so your place in the queue survives the trip. Judging a comment often needs the thing it is replying to.

That address is what makes the link possible, and there is no way to build one without it. A thread’s identity deliberately drops the origin, so /notes/leaving is one conversation whether a reader arrived at the apex domain, at www, over http or on a dev port. What is left is a path, and a path needs a home to be joined to. The address a comment reports cannot be that home: it is chosen by whoever posted the comment, so a link built from it would put a stranger’s URL in your dashboard one click from the buttons that publish comments. Charcha joins the path to the address you saved, which is why the host on that link is always yours.

One other thing reads the same setting. Akismet requires it and matches it against the sites authorised on your key. Nothing else does, and nothing else can work it out for you: your Worker’s own address is a workers.dev URL rather than your site.

With no address saved there is no link, and the card shows the page name as plain text. Nothing else changes.

Keys What it does
J or Next comment
K or Previous comment
A Approve, which publishes it
S Spam, which hides it and its replies
D Delete, which hides it and its replies
Z Undo the last decision
1 2 3 4 Pending, spam, approved, setup
Esc Dismiss the message bar
? This sheet

The sheet ? opens is generated from the same map the keys come from, so it cannot drift out of date.

A keystroke aimed at an input, a textarea, a select, a contenteditable element or anything carrying role="textbox" belongs to whatever you are typing into. Pressing s inside a text field does not mark a comment spam. Keystrokes carrying Ctrl, Cmd or Alt are ignored, and so are keystrokes in the middle of an IME composition. Shift is allowed only for ?.

After a decision the row below slides up and becomes the current one, so a morning’s comments clear at one keystroke each. At the bottom of the list it falls back to the row above.

One decision, one request to POST /admin/api/comments/:id/status.

Bulk actions are not built, and the reason is worth knowing if you were about to ask for them. The write is one statement per comment, so the obvious loop over a selection works perfectly on your test data and starts throwing at around fifty comments, where the per-request query budget ends. It needs a different shape of query, not a bigger button.

Marking a comment spam, or deleting it, moves the replies underneath it to the same status in the same write. Otherwise you take down a spam comment and leave three replies on the page answering something no reader can see, believing you removed a conversation when you removed half of one.

Approving works the other way. Each reply is still judged on its own.

The dashboard tells you what a decision took with it. Marked spam: Jordan, and 3 replies, not just the name.

Twelve seconds to take a decision back. Undo is one more status write, which is why Charcha can offer it instead of asking you to confirm every action before you take it.

There is one thing undo will not do, and it says so rather than letting you find out. It restores the comment and leaves the cascaded replies where the decision put them. You see The 3 replies stay spam. alongside the offer, and Its 3 replies are still spam. once you have pressed Z. Nothing records what the cascade moved, so there is nothing to put back.

The row returns to the index it came from rather than to the top, so undoing does not cost you your place in a 200-comment page. A new decision replaces the offer to undo the previous one. Z means “take back the thing I just did”, and holding two offers at once would make it ambiguous which.

Undoing an approval while you are looking at the Approved tab puts the row back in the Pending queue, which is a different list, so the announcement is all the feedback you get there.

The row disappears the moment you press the key and comes back if the request fails, and the keyboard follows it back rather than staying where the auto-advance left it. Without that you read “Could not approve the comment by X”, press A to retry, and approve the comment below X instead.

Loading, empty and failed are three different states on this screen. A queue that failed to load never renders as “nothing to moderate”.

By default, nobody. Every comment waits for you, and a deployment that never touches this setting carries on doing exactly that.

The Setup tab offers three choices, and they are a ladder rather than a menu: each one keeps doing what the one above it does.

Setting What goes straight onto the page
Hold every comment Nothing. The default.
Trust a commenter you have approved before Later comments from somebody whose first comment you approved
Also publish what your spam service calls clean That, and any comment a connected spam service actively checked and found clean

The middle one is not a guess about a comment. It replays a decision you made, and the person is identified by their email address and their hashed IP address together, so an email address on its own confers nothing. That matters more than it sounds, because an email address on a Charcha comment is optional and nobody verifies it. Anyone can type yours. Somebody who knows a regular’s address but is commenting from somewhere else is held, the same as a stranger.

Two consequences follow from the identity being half a hashed address, and both are worth knowing before you choose this.

Trust fades. The hashes are cleared on the retention window, 30 days out of the box, so somebody who has not commented for longer than that is held again like anyone else. That is deliberate rather than a limitation. The other side of it is that a household or an office sharing one connection can inherit each other’s standing, if they also know the email address.

Marking a trusted person’s comment as spam takes the trust away. Their next comment is held again and stays held until you approve one of theirs. Deleting a comment does not do this, because only Spam is a judgement about the commenter rather than about the comment.

The third acts only on a vouch, a spam service saying “I checked this and it is clean”. A comment that merely failed to look wrong is still held, which is the distinction that makes this safe to offer at all. Most of the checks measure the absence of a problem, and a script written against your form passes every one of them.

Two things are true of all three, and they are what stop this being a way to lose control of your own site:

  • A spam layer’s objection always wins. Anything held stays held, whichever setting you are on.
  • A setting that cannot work publishes nothing. With no spam service connected, the third option has nothing that can vouch, so it behaves exactly like the second. It fails towards holding rather than towards publishing.

An unreadable or hand-edited setting lands back on “hold every comment” rather than anywhere further down.

What this deployment has been given and what it has not. Four secrets are reported as yes-or-no answers, never as a value: RESEND_API_KEY, TURNSTILE_SECRET_KEY, IP_HASH_SECRET and AKISMET_API_KEY. Not a masked or truncated value either. A masked field cannot be proofread, and showing the first four characters is the same disclosure with a smaller number attached.

Alongside them the tab reports what needs no secret at all: which moderation policy is in force, and which of its four states the classifier is in, including whether the Workers AI binding it needs came up.

Four of the rows are settings rather than secrets, so the tab edits them in place: the moderation policy, your two notification addresses, your site’s home page address, and the allowed origins. Those live in your database, which is why a Worker can write them. A secret is the opposite case. A Worker cannot write its own, so every section that reports one offers instructions instead of a save button.

CHARCHA_DASHBOARD_PASSWORD is not on the list. Reaching the tab at all proves it is set. There is one thing the tab will say about it: if it is shorter than 15 characters you get told so, with NIST’s figure for a password used on its own without a second factor as the reason. Nothing refuses you entry over it. There is no password reset here, so a length floor on the login path would be a lockout rather than a defence.

It says what your deployment is, not how any of it works

Section titled “It says what your deployment is, not how any of it works”

Each section is three things: what this deployment’s answer is, the one thing to do about it if there is anything, and a link to the page here that explains why. The explanations used to be on the tab itself, and there were sixty-two paragraphs of them, which is a reasonable way to learn how Charcha works and an unreasonable way to change a setting.

So a warning appears only on a deployment that has the problem it warns about, and a deployment that has configured everything finds almost nothing to read. Nothing was deleted in the move. Every paragraph that left is on one of these pages, at greater length, and the section it left links to it.

The tab exists because the dashboard runs in a browser and cannot read the Worker’s environment. Before it, the way you learned that email notifications were off was noticing you had never had one.