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.
- 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
| Variable | Required | Purpose |
|---|---|---|
ADMIN_PASSWORD | yes | Shared password that unlocks a 5-minute admin session. |
GOOGLE_BOOKS_API_KEY | no | Raises the Google Books API quota; works unauthenticated without it. |
SEARCH_API_PROVIDER | no | serper (default) or brave. Only consulted when a book has no ISBN-13. |
SERPER_API_KEY | only if using Serper | serper.dev API key, 2,500 free credits. |
BRAVE_API_KEY | only if using Brave | Brave Search API key, alternate provider. |
GOODREADS_PROFILES | no | JSON 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.
- Install the Val Town CLI and log in:
deno install -grAf jsr:@valtown/vt vt
- From this folder:
vt create peoples-reading-club --no-editor-files(orvt cloneif the val already exists), which links.vt/state.jsonto a Val Town project. The entry file is namedindex.http.tsx— thehttpin the filename is what makesvtregister it as an HTTP trigger. - Set the environment variables above on the val (Settings → Environment
Variables, or
vt's env commands). vt pushto deploy,vt tailto watch logs.
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.
- Per-IP login throttle: dropped. The plan's
admin_login_attemptstable 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.otffont, then base64-embedded all three intofrontend/assets.generated.tsas plain TS string exports.index.http.tsxdecodes them at request time and serves them with realContent-Type/Cache-Controlheaders. Regenerate withpython scripts/gen_icons.pyif the icon design changes. - Serper wired now, not stubbed.
backend/goodreads.tsonly 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/:idacceptsstatus,priority,tags,addedBy,pitch, andgoodreadsUrl(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.
bookListViewwalks 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 withcreateElement/textContent, neverinnerHTML, then falls back to a normal navigation to/book/:idif the fetch fails or JS never loads. GOODREADS_PROFILESformat: a JSON array rather than a delimited string, so names with commas/colons aren't a parsing hazard. Validated againsthttps:+*.goodreads.combefore rendering, same as the admin-suppliedgoodreads_urlcorrection.
- XSS —
escapeHtml()infrontend/components.tsis the only path any DB-sourced string takes into HTML; the client JS builds the detail sheet withcreateElement/textContent, neverinnerHTML. - Open redirect /
javascript:URIs —validateGoodreadsUrl()inbackend/goodreads.tsrequireshttps:+ agoodreads.comhost + a/book/show/path before accepting a manualgoodreads_urlcorrection or a search-provider result; anything else is rejected with 400. - Cover URLs —
sanitizeCoverUrl()upgradeshttp:tohttps:and allowlistsbooks.google.com,books.googleusercontent.com, and*.gstatic.com; anything else is dropped tonull. - 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. - Auth —
requireAdminre-validates the bearer token againstadmin_sessions.expires_atin SQLite on every write (no caching), a password never being logged, a 5-minute session, and opportunistic expired-session purging on every login attempt.
vt push, thenvt tailfor live logs.- Drop the
bookstable in the SQLite admin panel, reload a tab → table and indexes are recreated automatically. curl -X DELETE .../api/books/1with no token / a made-up token / an expired token → 401 every time.- Add a book with a known ISBN-13 (e.g. search "Dune") → cover and
goodreads_urlpopulate with zero Serper/Brave calls (checkvt tailfor the absence of agoogle.serper.devrequest). Add an ISBN-less volume → either the search fallback finds it orgoodreads_urlstaysnullwithout throwing. - 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.
- Add a book whose pitch is
<img src=x onerror=alert(1)>→ renders as literal text on the card and detail page. PATCHa book'sgoodreads_urltojavascript:alert(1)→ 400.- 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 usingmanifest.webmanifest+ the generated PNG icons.
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.