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.
backend/db.ts)frontend/client.ts), progressive enhancement only| 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.
deno install -grAf jsr:@valtown/vt vt
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.vt's env commands).vt push to deploy, vt tail to 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.
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.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.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.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.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./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.escapeHtml() 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.javascript: URIs — validateGoodreadsUrl() 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.sanitizeCoverUrl() upgrades http: to https: and
allowlists books.google.com, books.googleusercontent.com, and
*.gstatic.com; anything else is dropped to null.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.requireAdmin 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.vt push, then vt tail for live logs.books table in the SQLite admin panel, reload a tab → table
and indexes are recreated automatically.curl -X DELETE .../api/books/1 with no token / a made-up token / an
expired token → 401 every time.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.<img src=x onerror=alert(1)> → renders as
literal text on the card and detail page.PATCH a book's goodreads_url to javascript:alert(1) → 400.font-size: 16px), "Add to Home Screen" installs
using manifest.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.