The People's Reading Club

A server-rendered book club tracker for one Val Town HTTP val. Three read-only tabs (Reading / Backlog / Finished), a real /book/:id page per book, and a 5-minute shared admin mode for writes. No client framework, no bundler, no blob storage — every asset (CSS, JS, manifest, icons, font) is a TypeScript string served from a route.

Stack

  • Hono router in index.http.tsx
  • Val-scoped SQLite (backend/db.ts)
  • Vanilla JS admin client (frontend/client.ts), progressive enhancement only
  • Google Books API for metadata, Goodreads ISBN-redirect (+ optional search fallback) for the Goodreads link

Environment variables

VariableRequiredPurpose
ADMIN_PASSWORDyesShared password that unlocks a 5-minute admin session.
GOOGLE_BOOKS_API_KEYnoRaises the Google Books API quota; works unauthenticated without it.
SEARCH_API_PROVIDERnoserper (default) or brave. Only consulted when a book has no ISBN-13.
SERPER_API_KEYonly if using Serperserper.dev API key, 2,500 free credits.
BRAVE_API_KEYonly if using BraveBrave Search API key, alternate provider.
GOODREADS_PROFILESnoJSON array of {"name": "...", "url": "https://www.goodreads.com/..."} shown in the footer. Entries that aren't https://*goodreads.com are silently dropped.

GOOGLE_CSE_ID is intentionally not used — Google Custom Search Engine is closed to new customers and EOL Jan 1 2027, so it never made it into this build; Serper (or Brave) is the fallback instead.

Setup

  1. Install the Val Town CLI and log in:
    deno install -grAf jsr:@valtown/vt vt
  2. From this folder: vt create peoples-reading-club --no-editor-files (or vt clone if the val already exists), which links .vt/state.json to a Val Town project. The entry file is named index.http.tsx — the http in the filename is what makes vt register it as an HTTP trigger.
  3. Set the environment variables above on the val (Settings → Environment Variables, or vt's env commands).
  4. vt push to deploy, vt tail to watch logs.

Data model

books(id, google_books_id, title, author, cover_url, description, isbn13, goodreads_url, status, priority, rank, tags, added_by, pitch, created_at, updated_at) plus admin_sessions(token, expires_at). initDb() in backend/db.ts runs CREATE TABLE IF NOT EXISTS + indexes, memoized per cold start and awaited from Hono middleware on every request — so dropping the books table in the SQLite admin panel and reloading recreates the schema with no redeploy.

Sort order is CASE priority WHEN 'high' THEN 0 ... END, rank ASC. New books get rank = COALESCE(MAX(rank), 0) + 1 scoped to their status.

Judgement calls where the spec was ambiguous

  • Per-IP login throttle: dropped. The plan's admin_login_attempts table and 10-failures/15-min lockout were cut per the "friends-only app" call — this is a shared password behind a countdown, not a public login form worth rate-limiting infrastructure for. The login digest comparison is still constant-time and nothing is logged.
  • Manifest icons: PNGs generated, not just SVG. Val Town vals can't store binary files, so scripts/gen_icons.py (Pillow, run locally, not part of the deployed val — see .vtignore) rendered 192×192 and 512×512 PNGs and the .otf font, then base64-embedded all three into frontend/assets.generated.ts as plain TS string exports. index.http.tsx decodes them at request time and serves them with real Content-Type/Cache-Control headers. Regenerate with python scripts/gen_icons.py if the icon design changes.
  • Serper wired now, not stubbed. backend/goodreads.ts only calls it when a book has no ISBN-13 (Google Books already supplies one for most books), so a typical add-book flow costs zero search-API calls.
  • Admin-editable fields are narrower than "everything." PATCH /api/books/:id accepts status, priority, tags, addedBy, pitch, and goodreadsUrl (validated). Title, author, cover, description, and isbn13 stay server-resolved from Google Books at creation time and have no edit path — this is what the "client never supplies title/cover/ description" security note implies, and it avoids reintroducing arbitrary metadata injection through the back door. If Google Books resolves the wrong edition, delete and re-add.
  • Reorder boundaries are computed server-side per render. bookListView walks the already-sorted list and disables a card's up/down button when the adjacent book (or list edge) has a different priority, rather than only relying on the API's 409 response.
  • Progressive-enhancement detail sheet fetches /api/books/:id (a public read endpoint) on card click and builds the overlay with createElement/textContent, never innerHTML, then falls back to a normal navigation to /book/:id if the fetch fails or JS never loads.
  • GOODREADS_PROFILES format: a JSON array rather than a delimited string, so names with commas/colons aren't a parsing hazard. Validated against https: + *.goodreads.com before rendering, same as the admin-supplied goodreads_url correction.

Security notes (OWASP)

  • XSSescapeHtml() in frontend/components.ts is the only path any DB-sourced string takes into HTML; the client JS builds the detail sheet with createElement/textContent, never innerHTML.
  • Open redirect / javascript: URIsvalidateGoodreadsUrl() in backend/goodreads.ts requires https: + a goodreads.com host + a /book/show/ path before accepting a manual goodreads_url correction or a search-provider result; anything else is rejected with 400.
  • Cover URLssanitizeCoverUrl() upgrades http: to https: and allowlists books.google.com, books.googleusercontent.com, and *.gstatic.com; anything else is dropped to null.
  • SSRF — every outbound fetch (Google Books, Goodreads, Serper, Brave) targets a hardcoded host; user input only ever lands in a query string or path segment, never in the fetched URL's origin.
  • AuthrequireAdmin re-validates the bearer token against admin_sessions.expires_at in SQLite on every write (no caching), a password never being logged, a 5-minute session, and opportunistic expired-session purging on every login attempt.

Verification checklist

  1. vt push, then vt tail for live logs.
  2. Drop the books table in the SQLite admin panel, reload a tab → table and indexes are recreated automatically.
  3. curl -X DELETE .../api/books/1 with no token / a made-up token / an expired token → 401 every time.
  4. Add a book with a known ISBN-13 (e.g. search "Dune") → cover and goodreads_url populate with zero Serper/Brave calls (check vt tail for the absence of a google.serper.dev request). Add an ISBN-less volume → either the search fallback finds it or goodreads_url stays null without throwing.
  5. Two same-priority backlog books swap order via the up/down buttons and the new order survives a reload; the top/bottom book in a priority group has that direction's button disabled.
  6. Add a book whose pitch is <img src=x onerror=alert(1)> → renders as literal text on the card and detail page.
  7. PATCH a book's goodreads_url to javascript:alert(1) → 400.
  8. Chrome DevTools device toolbar + Slow 4G throttling: fast first paint (CSS/JS are same-origin, no build step to wait on), no auto-zoom on input focus (all inputs are font-size: 16px), "Add to Home Screen" installs using manifest.webmanifest + the generated PNG icons.

Dropping the custom font entirely

Everything routes through one @font-face in frontend/styles.ts pointing at /static/fonts/RedOctoberLight.otf. To remove it: delete that @font-face block, delete the /static/fonts/RedOctoberLight.otf route in index.http.tsx, delete FONT_OTF_BASE64 from frontend/assets.generated.ts, and change the heading font stack (the h1, h2, .stamp, .wax-seal, .admin-star, .card__title selector) to drop 'RedOctober' — it already falls back to the Oswald webfont link in frontend/layout.ts's <head>, then 'Arial Narrow Bold'/Impact.