Cloudflare Worker + D1 backend for the Mos website. One Worker at
mos-api.caldis.me, multiplexing feature modules by path prefix. The static
site (GitHub Pages, output: export) calls it at runtime.
src/
index.ts # top-level router: dispatch by path prefix to features
lib/ # shared across features
env.ts # Env (bindings + secrets)
http.ts # CORS + json() helpers
turnstile.ts # Turnstile siteverify
hash.ts # sha256 (rate-limit / ip hashing)
geo.ts # resolveCountry (edge geolocation -> country code)
features/
wall.ts # sticky-note wall (/wall/*)
migrations/ # D1 schema (one shared database)
To add a feature: drop src/features/<name>.ts, register its routes in
src/index.ts, and add a migrations/000N_<name>.sql if it needs tables.
| Method | Path | Notes |
|---|---|---|
| GET | /wall/messages |
visible notes, newest first, max 800. Send x-wall-owner for per-note mine. |
| POST | /wall/messages |
create a note: { body, color, x, y, name?, owner, turnstileToken } |
| DELETE | /wall/messages/:id |
soft-delete. Owner-scoped via x-wall-owner (hide_reason='user'); with a valid x-wall-admin it hides ANY note (hide_reason='admin-del'). |
| GET | /wall/admin |
validate the admin secret (x-wall-admin) → 200 {ok:true} / 401. Lets the panel confirm the token before unlocking admin mode. Rate-limited (RL_ADMIN). |
| GET | /wall/admin/notes |
admin-only moderation ledger: EVERY note (visible + hidden) with hidden, hide_reason, x/y. Auth via x-wall-admin, rate-limited (RL_ADMIN). |
| POST | /wall/admin/notes/:id/restore |
admin-only: un-hide a note (hidden=0, hide_reason=NULL). |
| POST | /wall/admin/flagged/hide |
admin-only: hide EVERY AI-flagged note in one atomic UPDATE (→ admin-del). Returns { hidden: n }. Avoids per-note DELETEs tripping RL_ADMIN. |
No PATCH: a note's position is set at POST time and locked (decision D3 —
stick it and it stays). Rotation is not stored; the client derives it from id.
Admin mode is a hidden maintainer panel: on the live wall, click the title
10× to open a prompt for ADMIN_TOKEN. Once verified it's held in sessionStorage
and a delete affordance appears on every note. Auth is enforced server-side — the
client flag only shows the buttons; without a valid x-wall-admin the Worker rejects.
Admin mode also surfaces a moderation review (top-right "Review N" pill →
slide-out panel). It lists EVERY message and filters by hide_reason (chips: All /
Live / AI / Spam / User / Admin-del, each with a count). Per row you can Hide a
visible note (→ admin-del) or Restore a hidden one (→ visible). On the AI
filter a Hide all bulk button clears the sweep's ai-low-quality flags at once.
Each action calls its endpoint with a local per-row spinner.
cd website/server
pnpm install # standalone pnpm root; allowBuilds approves workerd/esbuild (pnpm 11+)
pnpm migrate:local # apply migrations to the local SQLite
pnpm dev # wrangler dev -> http://localhost:8787Create .dev.vars (gitignored) so secrets exist locally. Use Cloudflare's
"always passes" Turnstile test secret so POST works without a real widget:
TURNSTILE_SECRET=1x0000000000000000000000000000000AA
IP_SALT=dev-salt
ALLOWED_ORIGIN=https://mos.caldis.me,http://localhost:3000
ADMIN_TOKEN=dev-admin-token
(The matching always-passes site key for the frontend is 1x00000000000000000000AA.)
curl -s http://localhost:8787/ # health -> "mos-server ok"
curl -s http://localhost:8787/wall/messages # list
# create (any non-empty token passes with the test secret)
curl -s -X POST http://localhost:8787/wall/messages \
-H 'content-type: application/json' \
-d '{"body":"hello wall","color":"sky","x":0.5,"y":0.4,"name":"me","owner":"tok-123","turnstileToken":"dummy"}'
curl -s http://localhost:8787/wall/messages -H 'x-wall-owner: tok-123' # mine=true
# a 2nd POST within 60s from the same IP -> 429cd website/server
wrangler d1 create mos-server # paste database_id into wrangler.toml
wrangler d1 migrations apply mos-server --remote # build tables on the real DB
wrangler secret put TURNSTILE_SECRET # your real Turnstile secret
wrangler secret put IP_SALT # any long random string
wrangler secret put ADMIN_TOKEN # panel moderation; `openssl rand -base64 32`
wrangler deploy # publishes to mos-api.caldis.me (custom_domain)Then in the site build (GitHub Pages workflow) inject:
NEXT_PUBLIC_SERVER_URL=https://mos-api.caldis.meNEXT_PUBLIC_TURNSTILE_SITE_KEY=<your Turnstile site key>
Leaving NEXT_PUBLIC_SERVER_URL unset keeps the local seed fallback in
website/app/services/wall.ts for offline frontend dev.
Each note stores a country column (migration 0005), resolved server-side in
handlePost from Cloudflare's edge geolocation — request.cf.country, falling
back to the CF-IPCountry header, then 'XX'. It's an ISO 3166-1 alpha-2 code
(e.g. SG), 'XX' (unknown / local wrangler dev, where request.cf is absent),
or 'T1' (Tor). The selection logic is the pure resolveCountry (lib/geo.ts,
unit-tested in geo.test.ts).
This adds no extra request and stores no IP: the country rides on the inbound
request for free, and only the 2-letter code is persisted (the separate ip_hash
is for rate-limiting, not geo). It is stored only, never emitted — toPublicNote
doesn't expose it, so it stays backend-only, for aggregate stats:
# country distribution of visible notes
wrangler d1 execute mos-server --remote --command "SELECT country, COUNT(*) AS notes FROM wall_notes WHERE hidden=0 GROUP BY country ORDER BY notes DESC"Rows created before 0005 have country = NULL; every new note gets a value.
hide_reason records why a note was hidden — or, for ai-low-quality, why
it was flagged while still visible:
hide_reason |
hidden |
Meaning |
|---|---|---|
NULL |
0 | visible, nothing flagged |
user |
1 | the author self-deleted (DELETE /wall/messages/:id) |
spam |
1 | rule filter at POST time / the hourly sweep (lib/moderation: links + ad keywords) |
ai-low-quality |
0 | the sweep's AI judge thinks it's gibberish — still visible, awaiting your review |
admin |
1 | a human hid it by hand via the wrangler d1 command below |
admin-del |
1 | a human hid it from the admin panel (delete button, DELETE + x-wall-admin) |
Two automated layers, both in the hourly sweep (features/wall.ts → sweep):
- Rule spam (
lib/moderation.ts, shared with the POST handler so the door and the broom agree) — links banned + ad-keyword blocklist. Tune it there. - AI low-quality (
lib/aiJudge.ts) — a small Workers AI model flags gibberish likeDhdh. It only flags; you decide.ai_checkedensures each note is judged once, so AI usage stays within the free tier.
# review what the AI flagged (still visible until you act):
wrangler d1 execute mos-server --remote --command "SELECT id, substr(body,1,60) AS body FROM wall_notes WHERE hide_reason='ai-low-quality' AND hidden=0"
# agree → hide it by hand, tagged 'admin' so the audit trail is honest:
wrangler d1 execute mos-server --remote --command "UPDATE wall_notes SET hidden=1, hide_reason='admin' WHERE id=42"
# disagree → clear the flag (ai_checked stays 1, so it won't be re-judged):
wrangler d1 execute mos-server --remote --command "UPDATE wall_notes SET hide_reason=NULL WHERE id=42"
# inspect everything hidden:
wrangler d1 execute mos-server --remote --command "SELECT id, hide_reason, substr(body,1,60) AS body FROM wall_notes WHERE hidden=1 ORDER BY id DESC LIMIT 50"
# un-hide (restore) a note:
wrangler d1 execute mos-server --remote --command "UPDATE wall_notes SET hidden=0, hide_reason=NULL WHERE id=42"The account is on the Workers Free plan: every service hard-stops at its free limit instead of billing overage, so the worst case under attack is temporary unavailability, not a surprise bill. Cloudflare also adds no bandwidth/egress charges and free, unmetered DDoS mitigation. Layers in this Worker:
- POST — Turnstile + per-IP-hash rate limit (1 visible / 2 min, 8 / hour), plus the link + ad-keyword content filter.
- GET — Cloudflare Rate Limiting binding (
env.RL,[[ratelimits]]): 120 req / 60 s per IP, per-colo. A burst gets a cheap 429 before any D1 query. This guards D1/CPU, not the Worker request quota (the Worker still runs to evaluate the limit) — for that, add an edge WAF Rate Limiting Rule in the dashboard (Security → WAF → Rate limiting rules), which blocks before the Worker. - Admin (
GET /wall/adminverify + adminDELETE) — the real defense is theADMIN_TOKEN's entropy (openssl rand -base64 32= 256-bit → brute force is infeasible), compared in constant time (lib/admin.ts) to deny a timing side channel. On top, any request carryingx-wall-adminis per-IP rate-limited (env.RL_ADMIN, 10/60s) with a cheap 429 before any compare/D1. As withRLthis still runs the Worker — to block earlier, add an edge WAF Rate Limiting Rule on/wall/admin*. Failures return a flat401(no detail). - Workers AI — not reachable from any public route (only the hourly cron calls
it). Triple-capped: once per note (
ai_checked),AI_SWEEP_LIMITper run, andAI_DAILY_MAXper UTC day (ai_budgettable). Fail-open on any error. - Client — the site caches
GET /wall/messagesinlocalStoragefor 5 min, so reloads don't re-hit the API (website/app/services/wall.ts).