Comments for static sites — without the comment SaaS.
StaticLayer is a source-available, Cloudflare-native comment system designed for static websites. Your Worker. Your database. No centralized comment platform.
🌐 Website · Try the interactive demo · Docs · Security · Install guide · GitHub
| Product | Source-available, BYOC comment system running entirely in your Cloudflare account (Worker + D1) |
| Demo / simulator | apps/site — static website + interactive client-side simulator (npm run build:site) |
| Install | DEPLOY_TO_REAL_CLOUDFLARE.md · docs/installation/universal.md · docs.html |
| Examples | examples/ — vanilla, Astro, Hugo, Jekyll, Next.js static |
| Security | SECURITY.md · THREAT_MODEL.md · SECURITY_REVIEW.md · SECURITY_AUDIT_REPORT.md |
| Release audit | PUBLIC_RELEASE_AUDIT.md · docs/clean-room-checklist.md |
| Privacy | docs/PRIVACY_POLICY_TEMPLATE.md · privacy.html |
Structural comparison only — not a legal or marketing claim about Disqus's current terms. Verify Disqus's own privacy policy and pricing before you make representations to your users.
| Disqus | StaticLayer | |
|---|---|---|
| Where your data lives | Disqus-operated servers (a third-party comment SaaS) | Your Cloudflare account (Worker + D1) |
| Cookies / tracking | Uses cookies and analytics on its free tier (see their current policy) | No cookies, no fingerprinting, no analytics from the widget |
| Personal data handled | Processed by Disqus under their DPA/terms | Minimal: nickname + plain-text comment + timestamp; no IP persistence |
| Cost | Free (ad-supported) and paid tiers | Cloudflare free tier (Worker + D1) |
| License / lock-in | Proprietary | Source-available (Elastic License 2.0), deterministic protocol |
| Spam protection | CAPTCHA / ML on their side | Client-side Proof-of-Work + honeypot + time gate — no CAPTCHA, no friction, zero data |
TL;DR: Disqus is a centralized third-party comment SaaS. StaticLayer keeps the entire comment system inside your own Cloudflare account.
- 🪙 Proof-of-Work anti-spam — visitors solve a tiny client-side puzzle; no CAPTCHA, no forms of friction.
- 🧲 Zero-data anti-spam — hidden honeypot field (bots get silently dropped) + a 3-second time gate on challenge submissions (429 on too-fast posts). Pure behavioural checks: no content reading, no storage, no personal data.
- 🛡️ Anti-replay by design — a solved challenge can never be reused, even under concurrent races (proven by tests).
- 🧑💼 Moderation queue — new comments are
pendinguntil an admin approves them. - 🔒 XSS-safe — comments are plain text only (no Markdown, no HTML). Rendered exclusively with
textContent. - 🔐 CSRF-safe admin — session-bound, constant-time-verified tokens.
- 👤 Password-less admin sign-in (v1.7) — optional "Sign in with GitHub" via OAuth: free, no credit card, no Zero Trust plan. The GitHub token is used once to read your id and discarded — never stored; the allowlist (
GITHUB_ADMIN_IDS/GITHUB_ADMIN_LOGINS) decides who can open the console. The password stays as a fallback. (Cloudflare Access — "Sign in with Cloudflare" — is also supported.) - 🕵️ No tracking — the public widget sets no cookies and stores nothing in the browser.
- 🔁 Idempotent, verifiable deploys — a Desired State Engine observes → plans → applies → verifies. It never fails silently.
- 🔔 Telegram alerts (optional) — get a private notification when a comment awaits moderation; GDPR-minimal (no comment data in the message), configured from the admin panel.
- 🗳️ Polls — StrawPoll-style, privacy-first (no IP, no cookies), PoW-protected votes; optional anonymous one-vote-per-browser guard, optional multi-select (pick several options, one PoW), ranked results with leader + total-votes chip, and Change your votes on guarded multi polls. Created & managed from the admin.
- 🧵 Nested replies — up to 3 levels, with "Reply" inline, moderator-aware (pending parents hide the thread; deleted parents keep replies with a placeholder).
- 🧑💻 Owner replies — answer comments right from the admin console; your replies are approved instantly and shown with an Author badge (owner nickname is configurable in Settings).
- 🧩 Drop-in widget — a few lines of HTML on any static site (Astro, Hugo, Jekyll, plain HTML…).
- 🗂️ Comment layout (v1.5) — a unified "Start the conversation" card (empty message + form) and
data-reactions-position="top|bottom"to place the whole reactions bar together, above or below the comments. - 💬 Comment engagement (v1.6) — anonymous likes (PoW + per-browser guard), pin from the admin, visitor Report (zero data), thread sort (newest/oldest/best), relative timestamps, "Read more" for long comments.
- 📦 GDPR data export — download everything (comments CSV / full JSON) from the admin (Settings → Export).
- ♿ Accessibility + polish — visible focus states,
prefers-reduced-motionsupport, loading skeletons, refined micro-copy. - 🧩 Framework templates — ready-made components for Astro, React/Next, Vue/Nuxt, Hugo and Jekyll in
integrations/.
My Profile → API Tokens → Create Token with account permissions: Workers Scripts: Edit, Cloudflare D1: Edit, Account Settings Read.
Grab your Account ID from the same dashboard.
npm install
npm run build
export CLOUDFLARE_API_TOKEN="<your token>" # never stored on disk
export STATICLAYER_ADMIN_SECRET="$(openssl rand -hex 32)"
export STATICLAYER_SESSION_SECRET="$(openssl rand -hex 32)"
export STATICLAYER_POW_SECRET="$(openssl rand -hex 32)"
npx staticlayer init # observe → plan → apply → verifyThe CLI creates the D1 database, deploys the Worker, binds the 3 secrets via the Bulk Secrets API, then verifies the desired state. Apply the schema:
npx wrangler d1 migrations apply staticlayer --remote -c wrangler.jsonc📖 Full step-by-step (routes, custom domain, widget snippet, admin login): DEPLOY_TO_REAL_CLOUDFLARE.md
<script src="https://comments.yourdomain.com/widget.js"
data-staticlayer
data-endpoint="https://comments.yourdomain.com"
data-article-path="/your-article"
data-host-context="yourdomain.com"></script>Done. Moderate comments at https://comments.yourdomain.com/admin.html.
🔔 Telegram alerts (optional): in the admin → Settings → Telegram alerts, set Alerts = On, paste a bot token (create it with @BotFather in Telegram) and your chat id (see
https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates), then Save settings. When a comment awaits moderation you get a private message with a link to the queue — the message contains no comment data (privacy-first).
Create StrawPoll-style polls from the admin (Polls tab): article path, question, 2–10 options, and optional one vote per browser / multi-select toggles. Then embed on any page:
<div data-staticlayer
data-endpoint="https://comments.yourdomain.com"
data-poll-id="<poll id from the admin>"></div>
<script src="https://comments.yourdomain.com/widget.js" defer></script>Votes are anonymous (no IP, no cookies), protected by the same Proof-of-Work + atomic anti-replay as comments, and results are shown as a ranking (highest first, #1/#2 badges) with the leader highlighted, a "Leads by N votes" gap line and a total-votes chip in the heading. With the single-vote guard, the server issues an anonymous token that lives only in the visitor's browser (localStorage) and stores only a hash of it — so a returning visitor cannot vote twice, and no personal data ever leaves the device.
Multi-select: visitors pick several options and cast one vote (a single Proof-of-Work) for the whole set; every chosen option gets +1. On guarded multi polls they can also "Change your votes" — revoking their own previous anonymous votes (no new data stored).
Global polls: leave the article path empty when creating a poll and it appears on any
page where you embed its id. Optional display: data-poll-style="bars|percent|counts|minimal"
and data-poll-results="after|always".
Honest limitation: without an identity, "one vote = one person" cannot be guaranteed for visitors who clear their browser storage — the same trade-off every anonymous poll (StrawPoll included) accepts.
View results without voting: a small "View results" button under the voting buttons lets anyone peek at the live ranked results (and go back to vote) — purely client-side, no data stored.
Beyond plain comments, the widget and admin now offer:
- Likes — visitors can like a comment with one anonymous tap (same PoW + anti-replay as everything else). A per-browser guard stores only a hash of an anonymous token, so a returning browser can like each comment once; no personal data, no cookies.
- Thread sort —
data-comments-sort="newest|oldest|best"(default newest), with a small selector in the widget heading. Pinned comments always stay on top. - Pin — from the admin (Pin/Unpin on any comment card) to highlight a comment at the top of the thread.
- Report — visitors can flag a comment with a signed proof-of-work; the admin sees the count (⚠ N reports) in moderation. A flag stores only {comment, time} — zero data.
- Relative timestamps ("3 h ago") and "Read more" for long comments.
- Data export (GDPR) — Settings → Export: comments as CSV or the full dataset (comments, reactions, polls, votes, flags) as JSON. You are the data controller; move your data whenever you like.
Comments support replies up to 3 levels (comment → reply → reply). Click Reply on any comment: the same PoW + moderation pipeline applies. Moderation rules:
- A reply is visible only when it and its parent are approved.
- If a parent is deleted, its replies stay visible with a "parent comment removed" placeholder.
- The admin queue shows a "↳ reply to …" badge so you can moderate threads in context.
Owner replies: from the admin queue or published list, press Reply and answer as the site owner — the reply is approved immediately and shown with an Author badge. Set the owner nickname in Settings → Owner nickname (default "Site owner").
🧙 No terminal? The Web Installer (
npm run dev:installer) guides you through the same deploy with OAuth — scopes are least-privilege by design (docs/oauth-scopes.md).
Ready-made drop-in components that render the widget with the attributes you
configure — see integrations/README.md:
- Astro —
integrations/astro/Comment.astro(<Comment endpoint="…" />) - React / Next.js —
integrations/react/Comment.tsx(<Comments endpoint="…" />) - Vue / Nuxt —
integrations/vue/Comment.vue(<Comments endpoint="…" />) - Hugo —
integrations/hugo/comments.html(partial) - Jekyll —
integrations/jekyll/comments.html(include)
Visitor browser Your Cloudflare account
┌──────────────────┐ ┌──────────────────────────────────────┐
│ widget.js │ POST │ StaticLayer Worker │
│ + PoW Web Worker │ ───────► │ verify PoW → atomic anti-replay │
│ (solves puzzle) │ │ → store as 'pending' │
└──────────────────┘ │ D1 (SQLite): comments, challenges │
└──────────────────────────────────────┘
- The widget asks the Worker for a signed challenge (bound to your host + article).
- A Web Worker in the visitor's browser solves a Proof-of-Work puzzle.
- The Worker verifies the proof, atomically consumes the challenge (D1
batch()), and stores the comment as pending. - An admin approves it from
/admin.html— then it becomes public.
The runtime never calls any StaticLayer server. Your data never leaves your account.
Two behavioural layers on top of the PoW, both designed to never read, store or persist any content or personal data (GDPR-neutral by default):
- Honeypot — the widget renders a hidden field that real humans never see but naive bots love to fill. If it arrives filled, the server silently drops the submission and answers with a plausible fake "pending" — the bot learns nothing, and nothing is stored or consumed. 0 data.
- Time gate (3s) — a human takes seconds to type a comment; a scripted bot can
solve the puzzle and POST in milliseconds. The server rejects any submission
arriving sooner than 3 seconds after the challenge was issued with
429. The issue time is recovered from the signed challenge (expiresAt − TTL), so the server keeps zero state for the check. The widget waits the gate client-side, so real users never notice it.
Already in place: Proof-of-Work (cost-based integrity), atomic anti-replay,
edge rate limiting (RATE_LIMITER) and human moderation. Content heuristics
and duplicate-content hashing are deliberately not enabled by default — they would
require reading the comment text, against the project's zero-data principle.
Security is a first-class feature, not an afterthought — 123 tests, all invariants proven empirically (anti-replay concurrency, XSS, CSRF, retention, no-IP-persistence).
| Invariant | Guarantee |
|---|---|
| Challenge single-use | Exactly 1 of N concurrent requests with the same challenge is accepted |
| XSS | Plain text only; textContent rendering; strict UTF-8 |
| CSRF | Session-bound, constant-time double-submit on all admin mutations |
| No app-level IP persistence | The application DB stores no IP addresses |
| Zero-data anti-spam | Honeypot + time gate never read, store or persist content (behavioural only) |
| Never fail silently | The deploy engine re-verifies the live state after every apply |
📄
SECURITY_REVIEW.md(threat model + evidence) ·SECURITY_AUDIT_REPORT.md(claim-by-claim matrix) ·docs/cloudflare-assumptions.md(every Cloudflare fact verified against official docs).
npm run dev:demo # public demo on http://localhost:8787
npm run dev:installer # web installer wizard on http://localhost:8788Walk through the whole product like a real user — including PoW, XSS and moderation — with the checklist in HOW_TO_TEST_MANUALLY.md.
packages/protocol canonical binary PoW protocol (encoding, challenge signing, verification)
packages/runtime the Cloudflare Worker + D1 (public API, moderation, admin, cron)
packages/widget public widget + PoW Web Worker (vanilla JS)
packages/deployment-core library-first Desired State Engine + Cloudflare API client
packages/cli CLI shell (init / status / repair)
apps/installer Web Installer (OAuth + DSE deploy + wizard)
apps/demo public demo worker (data purged daily)
migrations/ D1 SQL migrations
tests/security anti-replay, CSRF, XSS, retention, no-IP-persistence
docs/ verified assumptions, OAuth scopes, privacy template
npm install # link workspaces
npm run build # protocol → widget → static assets → CLI → installer
npm test # full suite (123 tests)
npm run typecheck
npm run dev:demo # local public demo (port 8787)
npm run dev:installer # local web installer (port 8788)
npm run test:installer # OAuth least-privilege + deploy engine tests
npm run test:demo # demo daily-purge tests- MASTER_HANDOFF.md — single source of truth: architecture, invariants, decisions
- DEPLOY_TO_REAL_CLOUDFLARE.md — real deploy guide
- HOW_TO_TEST_MANUALLY.md — manual validation checklist
- docs/PRIVACY_POLICY_TEMPLATE.md — privacy-policy template for your site
- Source-available, not OSI "open source". The code is public under the Elastic License 2.0 (ELv2): anyone can read, modify, self-host and contribute, but may not resell it or offer it to third parties as a hosted/managed service. This keeps StaticLayer free to self-host while protecting the owner's right to sell or license it commercially.
- Owner rights. The copyright holder retains the right to (a) sell or license the software commercially, (b) grant custom licenses, and (c) decide the licensing of future releases — ELv2 never auto-converts to a permissive license, so the source can stay closed for future versions if ever needed.
- Premium features may ship in a separate package/repo under their own (proprietary) license — multi-site dashboard, advanced moderation, polls & votes, white-labeling, hosted installer, etc. The self-hosted core keeps working without them.
- No warranty, no liability. See TERMS.md: the software is provided "AS IS". You operate it on your own Cloudflare account (BYOC) and remain responsible for your content, your infrastructure, your security configuration and your legal compliance.
StaticLayer ships no telemetry: no analytics, no cookies, no "phone home" in the widget, the Worker or the installers — by design, so site owners stay out of GDPR consent-banner territory. How to gauge adoption without tracking:
- GitHub stars / watchers / forks are the public, aggregate signal (the site footer shows a small badge once the repo is public — it stays hidden otherwise, and the API call sends no personal data).
- Your own copies of
updates.json(the admin "Updates" tab fetches it) and the hosted installer are served from static/Cloudflare assets — the platform's own dashboards give you aggregate request counts if you ever need a rough number; nothing is logged or sent back to StaticLayer.