- A one-way “tell us what you think” suggestion box at pub.ihnyc-rc.org/feedback — a patron leaves a free-text message and, optionally, a 1–5 star rating, their name, and a way to reach them. It is not a support ticket / two-way chat system.
- The operator reads and triages submissions on
/admin/feedback, moving each note through three states: new → read → archived (and back). - Gated by the
FEEDBACK_ENABLEDenv flag and ships OFF — while off the patron page and submit endpoint both 404, and/feedbacklinks are stripped from rendered pages. The protected reader remains available while submissions are disabled. - Lightweight abuse controls: a per-browser token caps submissions at ~6 per rolling hour, and a truncated salted
ip_hashis stored for abuse triage only — never the raw IP. - Every piece of patron text is rendered admin-side via DOM
textContentonly (neverinnerHTML) — the same XSS guard used for the AI-prose surfaces.
Sources: worker.js (handleFeedbackSubmit / renderFeedbackPage / renderAdminFeedbackPage ~line 23029), worker/lib/feedback-store.js, migrations/content/0031_pub_feedback.sql
End-to-end picture
flowchart LR subgraph "Patron phone" FB["/feedback page"] end subgraph "Operator" ADMIN["/admin/feedback"] end subgraph "Worker" SUBMIT["POST /api/feedback<br/>(FEEDBACK_ENABLED gate)"] APIGET["GET /api/admin/feedback<br/>(list + counts)"] APIPOST["POST /api/admin/feedback/:id<br/>(set status)"] STORE["feedback-store.js<br/>create / list / counts / setStatus"] DB[("CONTENT_DB:<br/>pub_feedback")] end FB -- "message (+ optional rating/name/contact)" --> SUBMIT SUBMIT -- rate-limit + ip_hash --> STORE STORE --> DB ADMIN -- "Access identity + content.feedback" --> APIGET ADMIN -- "Mark read / Archive" --> APIPOST APIGET --> STORE APIPOST --> STORE STORE --> DB
Sources: worker.js (route table ~line 3447), worker/lib/feedback-store.js
Patron page (/feedback)
/feedback is a single self-contained HTML page (renderFeedbackPage(), no framework, served with Cache-Control: public, max-age=60). The form fields:
| Field | Required? | Limit | Notes |
|---|---|---|---|
| Message | Yes | 2000 chars | The only required field; an empty message is rejected client- and server-side |
| Rating | No | 1–5 stars | Tap-to-toggle star row; coerced to an int 1..5 or null |
| Name | No | 120 chars | Free text; shown as “Anonymous” in admin when blank |
| Email or handle | No | 200 chars | So the operator can follow up if the patron wants a reply |
page | (auto) | 200 chars | The referrer pathname (e.g. /menu), captured for context |
token | (auto) | 64 chars | A random per-browser id minted in localStorage (ih_fb_token) for rate-limiting only — not auth |
On submit the page POSTs JSON to /api/feedback. On success it swaps to a “Thank you — we read every note” panel with a “Leave another” button. A 429 shows “You’ve sent a lot just now — give it a few minutes.”
Sources: worker.js (renderFeedbackPage ~line 23091)
Gating (FEEDBACK_ENABLED)
feedbackEnabled(env) reads the FEEDBACK_ENABLED env flag (truthy values only). The flag ships OFF, which means:
GET /feedbackreturns404 Not Found.POST /api/feedbackreturns404({ error: "Not enabled" }) — nothing is written.- A small inline script (alongside the disabled-surface stripper at worker.js ~line 22601) removes every
<a href="/feedback">link from rendered pages, so patrons never reach a dead page.
The admin reader is not behind this flag. /admin/feedback and GET /api/admin/feedback keep working when the flag is off — the operator can still read and triage the backlog. The admin list response carries enabled, and the UI shows “submissions paused — FEEDBACK_ENABLED is off” when it’s false.
Sources: worker.js (feedbackEnabled ~line 23034, link stripper ~line 22601)
Anti-spam and privacy
Two best-effort checks reduce abuse without blocking ordinary submissions:
- Per-browser rate limit —
createFeedback()counts rows with the sametokenin the trailing hour; at 6 (RATE_LIMIT_PER_HOUR) it returns{ ok: false, error: "rate_limited" }→ HTTP429. Thetokenis a random per-device id fromlocalStorage, not an identity. If the count probe itself errors, the insert proceeds anyway. ip_hash— a truncated salted hash for abuse triage only. The worker computessha256Hex(TV_BUILD_ID + ":" + cf-connecting-ip)and stores the first 16 hex chars. The raw IP is never persisted, and the hash is computed best-effort (a failure just storesnull).
Neither token nor ip_hash is ever returned by the admin list query — listFeedback() selects only the display columns.
Sources: worker.js (handleFeedbackSubmit ~line 23036), worker/lib/feedback-store.js (createFeedback, RATE_LIMIT_PER_HOUR)
Operator triage (/admin/feedback)
The admin page is a single card column rendered by renderAdminFeedbackPage(). Tabs filter by status — New / Read / Archived / All — and a header line shows the counts (N new · N read · N archived).
Each card shows the name (or “Anonymous”), the star rating, a timestamp + originating page + status, the message body, and the contact line if provided. Per-card action buttons move the row between states:
| Button | Effect |
|---|---|
| Mark read | status → read |
| Back to new | status → new |
| Archive | status → archived |
Status changes POST to /api/admin/feedback/:id (setFeedbackStatus, which only accepts new / read / archived). All patron text is inserted via document.createElement + textContent — never innerHTML — so a hostile message can’t inject script into the operator’s browser.
Auth: the Worker verifies the Cloudflare Access identity and requires content.feedback. The browser does not receive the root admin token.
Sources: worker.js (renderAdminFeedbackPage ~line 23226, handleAdminFeedbackApi ~line 23064), worker/lib/feedback-store.js (listFeedback, feedbackCounts, setFeedbackStatus)
Storage (pub_feedback)
One additive migration, 0031_pub_feedback.sql on CONTENT_DB. Table shape:
| Column | Type | Notes |
|---|---|---|
id | TEXT PK | 16-hex random id |
message | TEXT NOT NULL | the note (≤ 2000 chars) |
rating | INTEGER | 1..5 or NULL |
name | TEXT | optional |
contact | TEXT | optional email / handle |
page | TEXT | originating pathname, for context |
status | TEXT NOT NULL DEFAULT 'new' | new / read / archived |
token | TEXT | per-browser id (rate-limit only) |
ip_hash | TEXT | truncated salted hash (abuse triage) |
created_at | TEXT NOT NULL | ISO timestamp |
updated_at | TEXT | set on status change |
Two indexes back the two hot reads: idx_pub_feedback_status (status, created_at DESC) for the admin list, and idx_pub_feedback_token (token, created_at DESC) for the rate-limit window scan.
Apply with:
wrangler d1 execute CONTENT_DB --remote --file=./migrations/content/0031_pub_feedback.sqlSources: migrations/content/0031_pub_feedback.sql
Related
- index — pub site overview
- photo-flash — another patron-input surface sharing the admin shell + auth model
- admin — admin identity and permission model
- api — API contract reference