• 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_ENABLED env flag and ships OFF — while off the patron page and submit endpoint both 404, and /feedback links 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_hash is stored for abuse triage only — never the raw IP.
  • Every piece of patron text is rendered admin-side via DOM textContent only (never innerHTML) — 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:

FieldRequired?LimitNotes
MessageYes2000 charsThe only required field; an empty message is rejected client- and server-side
RatingNo1–5 starsTap-to-toggle star row; coerced to an int 1..5 or null
NameNo120 charsFree text; shown as “Anonymous” in admin when blank
Email or handleNo200 charsSo the operator can follow up if the patron wants a reply
page(auto)200 charsThe referrer pathname (e.g. /menu), captured for context
token(auto)64 charsA 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 /feedback returns 404 Not Found.
  • POST /api/feedback returns 404 ({ 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 limitcreateFeedback() counts rows with the same token in the trailing hour; at 6 (RATE_LIMIT_PER_HOUR) it returns { ok: false, error: "rate_limited" } → HTTP 429. The token is a random per-device id from localStorage, 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 computes sha256Hex(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 stores null).

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:

ButtonEffect
Mark readstatus → read
Back to newstatus → new
Archivestatus → 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:

ColumnTypeNotes
idTEXT PK16-hex random id
messageTEXT NOT NULLthe note (≤ 2000 chars)
ratingINTEGER1..5 or NULL
nameTEXToptional
contactTEXToptional email / handle
pageTEXToriginating pathname, for context
statusTEXT NOT NULL DEFAULT 'new'new / read / archived
tokenTEXTper-browser id (rate-limit only)
ip_hashTEXTtruncated salted hash (abuse triage)
created_atTEXT NOT NULLISO timestamp
updated_atTEXTset 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.sql

Sources: migrations/content/0031_pub_feedback.sql


  • 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