Lesson 13 · Optional advanced lab
Optional lab: comments, CAPTCHA, and owner identity
define and implement a small manually moderated comments service while keeping articles static.
Sign in to save progress across devices
On this page
Lab scope: this lesson gives the architecture, implementation order, data/API contracts, provisioning procedure, and acceptance checks. It intentionally does not include the complete backend and frontend implementation. Finish and test that implementation before running deployment commands. The core blog requires none of these resources.
1. Decide whether to own comments
| Approach | Benefit | Ongoing responsibility |
|---|---|---|
| No comments; public contact link | Lowest maintenance | Handle conversations elsewhere |
| Hosted comment service | Provider maintains the service | Evaluate its current moderation, privacy, export, login, and pricing behavior |
| Your own Worker and D1 service | You control identities, moderation, and data | Maintain authorization, abuse controls, migrations, backups, and exception review |
Evaluate a hosted service against your needs before choosing it. An AI-generated prototype can accelerate implementation, but its code still needs real identity tests, failure tests, maintenance, and a recovery procedure.
Our small service supports plain text, guest posting, optional Google sign-in, one level of replies, and one owner. Omit uploads, reader editing, reactions, notifications, and password accounts at first. Reader edits would otherwise let previously approved text become unchecked text.
2. Keep static pages and API requests separate
Article request ----------------> built static HTML
Comment submission -------------> Worker API ---> D1 pending row
Owner approval -----------------> authorized API ---> approved row
Public comment read ------------> approved rows only
The static moderation page at /comments-admin/ is just a UI. Hiding its link or adding noindex provides no authorization. Every moderation and export endpoint must verify the owner independently.
Plan these source responsibilities:
| File/module | Responsibility |
|---|---|
worker/index.ts |
Route requests, validate inputs, expose public/private contracts |
worker/auth.ts |
Google token verification, one-use nonces, sessions, owner checks |
worker/security.ts |
Exact-origin checks, CAPTCHA, rate budgets, response headers |
migrations/0001_comments.sql |
Comments, session, nonce, and budget tables/indexes |
src/components/Comments.astro |
Accessible form and public list |
src/scripts/comments.ts |
Submission, failure recovery, and public pagination |
src/pages/comments-admin.astro |
Sign-in and moderation shell |
src/scripts/comments-admin.ts |
Authenticated moderation and delete confirmation |
src/pages/privacy.astro |
Actual comment/login/processing and retention disclosure |
Use a reviewed library for JWT verification; do not invent a signature verifier. Keep a separate local test setup with mocked providers and a separate deployed staging setup with real provider credentials. Neither should have a production bypass.
3. Define the data and API contract first
Comments need an ID, published-post path, optional parent ID, author name, optional verified Google subject, body, pending/approved/rejected status, private reason, creation time, concurrency version, and unique submission key with a payload hash. Sessions should store a hash of a random opaque token, subject, name, and expiry. Nonces and abuse budgets also need expiry.
Index public reads by post/status/ID and owner review by status/ID. Use parameterized SQL and bounded pagination. Every public read, including replies, must exclude pending/rejected/deleted records and replies whose parent is no longer public. Return an explicit field list; avoid SELECT * flowing into public JSON.
| Endpoint | Contract |
|---|---|
GET /api/comments/config |
Public feature switches and the current viewer’s minimal state |
GET /api/comments?post=... |
Approved comments only, public fields and bounded pagination |
POST /api/comments |
Validated submission, initially pending; guests also complete CAPTCHA |
POST /api/comments/auth/nonce |
Short-lived one-use nonce bound to an HttpOnly cookie |
POST /api/comments/auth/google |
Server-verified Google ID token exchanged for an opaque session |
POST /api/comments/auth/logout |
Revoke session and clear cookies |
GET/POST /api/comments/admin |
Owner-only read/moderation with concurrency checks |
GET /api/comments/export |
Owner-only private export |
Use Cache-Control: no-store for identity, private responses, and sensitive API errors. The simplest initial public API can use it too. Require matching origin and JSON content type for mutations. Render names and bodies as plain text in the browser; never inject a comment as HTML. Preserve form text when a request fails.
Reject unknown/unpublished post paths using a build-generated allowlist that is bundled with the same deployment’s article assets. Never infer publication from the submitted URL alone. Check parent/post relationships and reply depth on the server.
4. Provision separate test resources
Create wrangler.comments-staging.jsonc for a separate Worker and database. Add a separate queue only in the next lab. Its SITE_ORIGIN must equal its actual HTTPS origin. It must declare no production Custom Domain and must not bind production data.
Create the staging database:
pnpm exec wrangler d1 create my-blog-comments-staging
Put the returned database ID into the staging config’s d1_databases binding named COMMENTS_DB. Keep migration files in migrations/. Once the implementation and config exist:
pnpm exec wrangler d1 migrations list COMMENTS_DB --local --config wrangler.comments-staging.jsonc
pnpm exec wrangler d1 migrations apply COMMENTS_DB --local --config wrangler.comments-staging.jsonc
pnpm exec wrangler d1 migrations list COMMENTS_DB --remote --config wrangler.comments-staging.jsonc
pnpm exec wrangler d1 migrations apply COMMENTS_DB --remote --config wrangler.comments-staging.jsonc
Read the target database each time; local and remote are different databases. Back up existing data before migrations and add new migrations rather than editing already-applied ones. See D1 migrations.
To route only the API through the Worker, extend the configuration with a main entry and these fragments:
"main": "worker/index.ts",
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"run_worker_first": ["/api/comments", "/api/comments/*"],
"html_handling": "force-trailing-slash",
"not_found_handling": "404-page"
},
"vars": {
"SITE_ORIGIN": "https://YOUR_STAGING_HOSTNAME",
"COMMENTS_ENABLED": "false",
"AUTO_APPROVE": "false",
"TURNSTILE_SITE_KEY": "YOUR_STAGING_SITE_KEY",
"GOOGLE_CLIENT_ID": "YOUR_STAGING_WEB_CLIENT_ID"
}
These fragments do not include the D1 binding or the actual Worker implementation. Fill those in before a dry run. Serve the public article assets normally even if the comments subsystem is unavailable.
5. Configure CAPTCHA and optional Google sign-in
Create a Turnstile widget restricted to the exact staging hostname. The public site key goes in configuration; the secret stays in Wrangler secrets. On the server, validate the token through Siteverify and check success, hostname, and your expected action, such as comment. Client-side success alone authorizes nothing. See Turnstile server validation.
Create a personal Google Cloud project for sign-in. Basic Google Identity Services setup does not require starting a paid compute trial. Keep billing choices separate. Configure Google Auth branding, a support address, external audience, and a Web application client for the exact authorized JavaScript origin. For this ID-token sign-in flow, do not add redirect URIs unless you implement a redirect flow. See Google Identity setup.
The support email must be an eligible identity available to the project. Merely forwarding contact@your-domain into Gmail does not make it a Google identity. Use an eligible address initially or deliberately configure the required account/project access. Support email is not the moderator identity.
In the Google callback, verify signature, issuer, audience, expiry, and the one-use server nonce. Identify the owner by Google’s verified sub claim, not a display name or client-supplied email. Obtain that value from the successful server-verified sign-in of the intended owner. See Google ID-token verification.
Create Secure, HttpOnly, same-site session cookies and revoke sessions on logout. A strict session cookie needs a sign-in flow compatible with it; test the popup/client-token flow on real browsers. Never log or send ID tokens, session cookies, or credentials to an AI assistant.
Store secrets interactively:
pnpm exec wrangler secret put TURNSTILE_SECRET_KEY --config wrangler.comments-staging.jsonc
pnpm exec wrangler secret put IP_HASH_SECRET --config wrangler.comments-staging.jsonc
pnpm exec wrangler secret put OWNER_GOOGLE_SUB --config wrangler.comments-staging.jsonc
Generate a cryptographically random IP-budget hashing secret and keep it in your password manager. A keyed hash is pseudonymous abuse bookkeeping, not a guarantee of anonymity. Google client IDs and Turnstile site keys are public; their matching verification logic still matters.
6. Release manual moderation first
Run implementation tests and a staging dry run; deploy with both flags false. Verify owner sign-in while public comments are disabled. Then enable public comments only in staging, with AUTO_APPROVE="false". All accepted comments should stay pending until the owner approves them.
| Acceptance case | Expected result |
|---|---|
| Guest submits with valid CAPTCHA | Pending row; absent from public API |
| Missing, expired, wrong-host, or wrong-action token | No accepted submission |
| Owner approves the pending row | The public API returns only public fields |
| Non-owner signs in | Authenticated, but no moderation/export access |
| Forged display name matches owner | No owner rights |
| Logout then reuse old session | Denied |
| Duplicate submission key, same payload | No duplicate comment |
| Same submission key, different payload | Conflict; no silent overwrite |
| Deleted/rejected parent | Its child does not remain publicly exposed |
| Missing D1 or comments disabled | API fails safely; static article still works |
Test owner and non-owner identities in different browsers, including Safari. Confirm the email shown by Google’s account chooser, since display names can match. Test Cancel and Escape on an accessible in-page delete dialog before testing deletion on disposable staging rows.
For production, provision a new database/widget/client and corresponding secrets/origin. Bootstrap and test the owner separately. Change the Google audience from Testing to public production when you intend public sign-in; review Google’s current requirements for the scopes you request. Basic identity sign-in must not ask for Gmail or Drive access.
Write the privacy page to match what actually happens: stored public name/text, verified identity used privately, session data, spam-processing providers, retention, and a contact for removal. Keep manual approval enabled until you complete the next lab’s screening evaluation.
Checkpoint
Your implementation has passed the acceptance table in staging. Pending data stays private, the verified owner can moderate, another real account cannot, and guest submissions still require CAPTCHA. If those checks are unfinished, keep the public feature disabled. The blog itself is already complete.