Cavu Reader #
A TanStack Start app for reading books published on the AT Protocol via
site.standard.publication + page.cavu.book (see ../DESIGN.md). No
backend storage, no login — everything is fetched straight from the
author's PDS with unauthenticated XRPC calls.
pnpm install
pnpm dev # http://localhost:3000, redirects to the default book
pnpm typecheck
pnpm build
Routes #
| Route | Purpose |
|---|---|
/ |
Discovers the newest book publication in the default repo (src/lib/defaultBook.ts) and redirects |
/$did/$rkey |
Book overview: title page + table of contents |
/$did/$rkey/read/$doc |
Reader; ?mode=scroll for infinite scroll (default paginated), ?at=end lands on the last page |
/.well-known/site.standard.publication/$did/$rkey |
standard.site publication verification (path-suffixed "Non-root Publications" form) |
standard.site discovery #
Reader pages emit <link rel="site.standard.document"> (chapters) and
<link rel="site.standard.publication"> (overview, hint only). The
well-known endpoint is the verification authority: it fetches the
publication record fresh from its PDS and returns its AT-URI only if
the record's url equals the canonical URL we'd serve it at —
{READER_ROOT_URL}/{did}/{rkey} — so a shared reader domain never
affirms books that don't claim to live here. Set READER_ROOT_URL to
the deployment origin (e.g. https://reader.example); it falls back to
the request origin in dev. The studio stamps the matching claim on every
book it creates (assembleCreate's rootUrl).
KOReader sync (kosync) #
The server speaks KOReader's progress-sync API at /kosync so an
e-reader's position can be written to the reader's own repo as
page.cavu.progress. Because KOReader only ever sends a username and
md5(password), the server holds its own confidential OAuth session per
connected reader (scope repo:page.cavu.progress), obtained through
"Connect KOReader" on the account page (/login), which also issues the
sync password KOReader stores. Configuration:
SYNC_OAUTH_KEY— ES256 private JWK (JSON, withkid) for the confidential client. Generate withnpx tsx scripts/gen-sync-key.tsand set it as a secret (fly secrets set SYNC_OAUTH_KEY="$(npx tsx scripts/gen-sync-key.ts)"). Unset → the account panel reports sync as not enabled. The client metadata lives at/oauth/sync-client-metadata.json(itsclient_idmust be https, so the connect flow only runs against a deployed origin or a local stack with anhttp://resolver).HANDLE_RESOLVER_URL— handle → DID resolver (defaulthttps://public.api.bsky.app; the vivarium env points it at the local stack).VITE_HANDLE_RESOLVER_URLis the client-bundle twin.
Readers then set KOReader → Tools → Progress sync → Custom sync server to
https://cavu.page/kosync and log in with their handle + the issued
password (Register is refused — accounts come from the connect flow).
How a position gets from a file to a record: every EPUB build
(server/export/epub.ts) is deterministic and registers its KOReader
digests — the file's partial MD5 (KOReader's default "binary" matching)
and md5(filename) — together with a spine map in kosync_documents
(server/kosyncDocuments.ts). PUT /kosync/syncs/progress stores the raw
push (kosync_progress, so any device gets it back verbatim) and, when
the digest is one of ours, translates the crengine XPointer
(/body/DocFragment[k]/body/section[s]/p[n]…) onto the book with
lib/kosyncMap.ts, merges it into the reader's page.cavu.progress
record with the same rules as the web reader (lib/progressRecord.ts)
and writes it through the stored OAuth session (server/kosyncProgress.ts).
Chunks the device moved past since its last push count as finished. GET /kosync/syncs/progress/:digest answers with whichever is newer — the raw
push, or the repo record translated back into an XPointer (device
"Cavu (web)"). Files pushed from KOReader that aren't Cavu EPUBs still
sync between devices; they just don't reach the repo.
Send to Kindle #
Readers register their Kindle's Send-to-Kindle address (…@kindle.com)
on the account page (/login); books whose author enabled downloads get
a "Send to Kindle" button that mails the EPUB there (server/kindle.ts,
tables kindle_devices / kindle_sends). Mail goes out through
Comail over SMTP submission (server/kindleMail.ts
— the HTTP Send API has no attachments). Configuration, all environment:
COMAIL_API_KEY— theatmos_…key;KINDLE_FROM_EMAIL— the sender, on a domain enrolled under the Comail DID. Either unset → the feature is hidden (button and account panel don't render;/api/kindle/sendanswers 503); sends already queued are held until it comes back.COMAIL_SMTP_USER(default: the sender's domain),COMAIL_SMTP_HOST(smtp.atmos.email),COMAIL_SMTP_PORT(587, STARTTLS).
Readers must add KINDLE_FROM_EMAIL to their approved senders at
amazon.com/sendtokindle (the account panel tells them the address). A
send is attempted immediately; relay rate limits / network trouble leave
it queued and a five-minute sweep retries on a backoff for three days,
then marks it failed.
Email notifications #
Cavu emails a reader when a book they follow gains chapters, or an author
they follow starts a new book. Email is a delivery channel on a follow,
not a second subscription: the follows are records in the reader's own
repo (site.standard.graph.subscription for a book,
page.cavu.graph.authorSubscription for an author), mirrored into
subscribers / author_subscribers so fan-out is one query. Signed-in
readers only, and the address is confirmed before anything is sent to it
— atproto has no email scope to borrow, and double opt-in is also what
stops an account pointing our relay at somebody else's inbox.
Configuration, all environment (plus the shared COMAIL_* values above):
NOTIFY_FROM_EMAIL— the sender, e.g.notifications@cavu.page, on a domain enrolled under the Comail DID. Deliberately not the same address asKINDLE_FROM_EMAIL: readers hand-approve that one at Amazon, and sending bulk mail from it risks their Kindle delivery. Unset → the feature hides (account panel and per-book opt-in don't render;/api/notify/emailanswers 503). Author follows keep working: they are a record, not a mail feature.NOTIFY_REPLY_TO— optional monitored address; omitted otherwise.
How a release becomes mail (server/notify.ts): the indexer already
records spine growth in releases (server/shelfIndex.ts upsert), and
now records a book from an already-indexed author in new_books. Both
are written only from the live jetstream path — the live flag on
upsert — because the startup backfill meets every book for the first
time and would otherwise mail the whole catalogue to everyone. A
one-minute sweep expands each event into one notify_sends row per
recipient and mails the queue, backing off per attempt and giving up
after three days. Every step is idempotent: jetstream delivery is
at-least-once, and UNIQUE (kind, event_key, did) is what makes a replay
free. A replace-chunk publish produces no publication event and so fires
nothing — lib/publisher/diff.ts enforces that on the write side.
Every message carries RFC 8058 one-click unsubscribe headers scoped to
the channel it came from, plus a link to /notify/unsubscribe/:token
where the reader can adjust or restore either channel without signing in.
/api/notify/unsubscribe is the only endpoint in the app that mutates
without a session cookie; the token is the whole authorization, and the
action can only turn mail off.
Bounces are only partly visible: SMTP submission surfaces synchronous refusals, which mark the address bounced and stop further sends, but asynchronous bounces go to the envelope sender and never reach us. Real bounce processing would need a return-path mailbox or webhook.
Verify the whole thing offline with npx tsx scripts/notify-probe.ts
(add --from /path/to/cavu.db to test the migration against real data).
It runs with no transport configured, so nothing can leave.
How reading works #
Data layer (src/lib/atproto.ts): DID → PDS resolution (plc.directory /
did:web) and com.atproto.repo.getRecord fetches, both behind in-memory
promise caches so loaders, prefetches, and the scroll window share one
fetch per record. The reader keeps a three-record window — current,
previous, and next spine documents — warm at all times.
Paginated mode (PaginatedReader): the chunk's blocks flow into
viewport-width CSS columns; turning a page translates the column strip.
Arrow keys / PageUp / PageDown / Space and edge click-zones page through;
paging past either end of a chunk navigates to the neighboring spine
document (?at=end when arriving backwards).
Comic spreads (ComicReader): chunks whose content is
page.cavu.pages (pre-paginated image pages) always render as a
two-page spread — the mode toggle disappears while reading them, and
text and comic chunks can mix in one spine. lib/spreads.ts lays pages
into left/right slots in reading order, honoring the book's
pageProgression (rtl flips slot order and turn direction for manga),
per-page spread hints (left/right pin a page to a side, leaving
the other slot blank as print does; center takes the whole spread),
and wider-than-tall pages, which get a spread to themselves. The same
keys and click-zones as paginated mode turn pages — arrows follow the
visual direction — neighboring spreads' images are preloaded, and edge
turns hand off to neighboring spine documents. A comic chunk reached in
scroll mode (mixed spine) renders as a plain vertical page column.
Scroll mode (ScrollReader): at most three spine documents are
mounted at once. Crossing a chunk boundary shifts the window and syncs the
URL (replace, no scroll reset). Sections filling in or dropping out
above the viewport are compensated with a scroll anchor so the text never
jumps. TOC links to chapters outside the window reset it.
Content rendering (Blocks.tsx) covers the block set in the wild:
Leaflet text/header/blockquote/image/horizontalRule with
byte-range facets, plus page.cavu.blocks.chapterTitle (the explicit
chapter-start marker: chapter-opening treatment, a fresh page in
paginated mode, a large gap in scroll mode — record boundaries stay
invisible), page.cavu.blocks.verse (stanzas with hard line breaks),
and page.cavu.blocks.toc (rendered as a linked contents nav). Unknown
block types are skipped.
Lexicon types are imported type-only from the repo's generated code
(../src/lexicons/types via the @lexicons/* path alias), so the reader
bundles none of the validation machinery.