Files
bookshelf/docs/SPEC.md
T
Spriteandclaude 1385ad286f docs: the server is on the sprite and internet-exposed
The long-standing open question "where will the server actually live" is
answered for now: it stays on this sprite, bound to 0.0.0.0:8090 and published
over HTTPS by the sprite proxy. Several docs asserted the opposite — HANDOFF
said "127.0.0.1:8090, deliberately NOT internet-exposed (no --http-port, so the
sprite proxy can't reach it)", which is flatly wrong today.

The consequence is the part worth writing down: PocketBase's API rules are now
the only thing between this library and the internet. There is no NAT, no VPN,
no reverse proxy. So the anonymous-access curls stop being a formality, and they
have to run against the PUBLIC hostname — localhost cannot tell you what the
world can reach. Re-verified that way: books/shelves/bookcases LIST all 403,
self-registration 403, health 200.

One gap found while re-verifying, recorded but NOT fixed: users LIST answers 200
with an empty array instead of 403. Nothing is disclosed — two real accounts
exist and the listRule filters both out — but it is the same wrong-signal quirk
pb_hooks/main.pb.js exists to close, and that hook never listed the users
collection.

SPEC's offline-first rationale is amended rather than its rule: the reason is no
longer residential NAT but a sprite that suspends when idle and wakes on
request. The rule is unchanged and does not depend on which.

server/deploy/ still documents systemd/Docker/Tailscale on home hardware; it now
says up front that this is the intended end state, not what is running.

Also corrected, since it was adjacent and plainly false: README still claimed the
app had never run on a physical device. It has, since 2026-09-09. What is true is
that no *automated* test runs on a device — there is no emulator on this box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPpdG8VnRfS3KkisR3HUAE
2026-09-12 17:32:17 +00:00

9.2 KiB

Bookshelf — authoritative spec

Two-person shared home library. Android app + self-hosted PocketBase. ALL workers must follow this exactly. Do not invent alternative names.

Non-negotiables

  • Offline-first. The server is often slow or unreachable. Every read comes from Room. Every write lands in Room first, syncs later. No screen may block on network. (The original reason was residential NAT; the server currently runs on a sprite that suspends when idle and wakes on request, so the same rule holds for a different reason. The requirement does not depend on which.)
  • Private. No public registration. Auth required for all data access. As of 2026-09-12 the server is INTERNET-EXPOSED, so these rules are the only thing protecting the library — not defence-in-depth behind a home NAT.
  • Server URL is NOT hardcoded; user enters it on first run.

Repo layout

~/bookshelf/ server/ PocketBase binary(gitignored), pb_migrations/, setup-schema.sh, deploy/ app/ Android Gradle project docs/ this spec

Android

  • applicationId/namespace: org.modg.bookshelf
  • minSdk 26, compileSdk 37, targetSdk 37, JDK 21, Kotlin, Jetpack Compose, Material 3
  • SDK at ~/toolchain/android-sdk ; JDK at ~/toolchain/jdk21
  • DI: manual AppContainer held by Application. NO Hilt/kapt. Room uses KSP.
  • Libs: Compose BOM, room(+ksp), retrofit2 + kotlinx-serialization converter, okhttp logging, coil3 compose, camerax(core/camera2/lifecycle/view), com.google.mlkit:barcode-scanning, androidx.work runtime-ktx, datastore-preferences, navigation-compose, lifecycle-viewmodel-compose, accompanist-permissions (or manual)

Package structure (org.modg.bookshelf.*)

data.local Room: entities, daos, BookshelfDatabase, Converters data.remote PocketBaseApi (retrofit), dtos, PbAuthInterceptor data.metadata OpenLibrary + GoogleBooks lookup data.repo BookRepository, LocationRepository, SyncEngine, AuthRepository data.prefs SettingsStore (DataStore) ui.theme Color/Type/Theme ui.library, ui.detail, ui.scan, ui.locations, ui.settings, ui.setup ui.nav BookshelfNavHost

Data model — Room mirrors PocketBase 1:1

IDs: 15-char lowercase alnum, GENERATED CLIENT-SIDE for new records (PocketBase accepts client-supplied ids on create). Never remap ids after push.

BookEntity(id PK, title, subtitle, authorsJson, isbn13, isbn10, publisher, publishedDate, pageCount:Int?, description, coverUrl, coverSourceUrl, shelfId:String?, notes, addedBy, deleted:Boolean, createdAt:Long, updatedAt:Long, syncState:SyncState, localCoverPath:String?) BookcaseEntity(id PK, name, note, position:Int, deleted, createdAt, updatedAt, syncState) ShelfEntity(id PK, bookcaseId, label, position:Int, deleted, createdAt, updatedAt, syncState)

enum SyncState { SYNCED, PENDING_CREATE, PENDING_UPDATE, PENDING_DELETE }

All queries filter deleted = 0. Deletion is ALWAYS soft (tombstone) so sync can propagate it and nothing is silently lost from a shared library.

PocketBase schema (collections)

bookcases: name(text,req), note(text), position(number), deleted(bool) shelves: bookcase(relation->bookcases,req,maxSelect 1), label(text,req), position(number), deleted(bool) books: title(text,req), subtitle(text), authors(json), isbn13(text), isbn10(text), publisher(text), published_date(text), page_count(number), description(text), cover(file,maxSelect 1,image mimes,thumbs 100x150+300x450), cover_source_url(text), shelf(relation->shelves,maxSelect 1), notes(text), added_by(relation->users,maxSelect 1), deleted(bool) All three get autodate created/updated. Indexes: books(isbn13), books(updated), shelves(updated), bookcases(updated).

API rules — all of list/view/create/update/delete on the three collections: "@request.auth.id != """ users collection: createRule = null (SUPERUSER ONLY — this is what keeps the world out), listRule/viewRule = "@request.auth.id != """, updateRule = "id = @request.auth.id", deleteRule = null. NOTE: rule "" means PUBLIC in PocketBase; null means superuser-only. Do not confuse.

Sync design (SyncEngine)

Pull: GET /api/collections/{c}/records?filter=(updated>'{cursor}')&sort=updated &perPage=200&page=N — paginate to exhaustion. Cursor per collection in DataStore, stored as PB UTC string. Include tombstones. Push: records where syncState != SYNCED. PENDING_CREATE -> POST (with our id), PENDING_UPDATE -> PATCH, PENDING_DELETE -> PATCH {deleted:true}. On 404 for update/delete: drop local record. On 409/duplicate id: switch to PATCH. Order: push THEN pull (so our writes come back canonical). Conflict: last-write-wins on updated. Document this in README; do not build anything cleverer. Covers: on save, app downloads cover from metadata source and multipart-uploads it to the book's cover file field, so covers survive upstream URL rot. If offline, store localCoverPath and upload on next sync. Trigger: app start, manual pull-to-refresh, WorkManager periodic (~6h, network-constrained). Never let sync failure surface as a crash or a blocking dialog — a quiet status line only.

Book metadata lookup

Primary Open Library: https://openlibrary.org/api/books?bibkeys=ISBN:{isbn}&format=json&jscmd=data Fallback Google Books: https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn} (no key) Cover: https://covers.openlibrary.org/b/isbn/{isbn}-L.jpg else GB imageLinks (force https, zoom=2) Merge: prefer whichever has a title; fill blanks from the other. Cover URL comes from a source that REPORTS one. Never synthesize the by-ISBN cover URL as if it were evidence: for an edition with no art that endpoint returns 200 + a 43-byte 1x1 transparent GIF, which loads "successfully" and paints nothing. As a last resort it may be used only with ?default=false, which makes a miss a 404.

Lookup outcome is THREE-WAY, never a bare null. A source that could not be reached must never be reported to the user as a book that does not exist: Found(metadata) - at least one source returned a record NotFound - EVERY source answered authoritatively and none had it Unavailable - no source could be reached (non-2xx, timeout, transport error) and none of the reachable ones had it UI: Found -> the save sheet. NotFound -> manual entry pre-filled with the scanned ISBN. Unavailable -> a retry affordance, with manual entry as the escape hatch; it must NOT claim the book is unknown.

Barcode scanning

CameraX Preview + ImageAnalysis -> ML Kit BarcodeScanning (EAN_13, EAN_8, UPC_A). Validate ISBN-13 checksum before lookup; ignore non-book barcodes. Debounce repeats. A rejected barcode is NOT silent: the camera screen must say a code was read and was not a book ISBN, or the user cannot tell a non-book barcode from a dead camera. Throttle that message — a non-book barcode sits in frame emitting continuously. Continuous mode: after a save, stay on camera for the next book (shelving a box of books is the real use case). Show a running "added this session" count. Handle: camera permission denial, torch toggle, and a manual-ISBN-entry escape hatch.

Design language — "feels like books"

Warm paper, dark mahogany, gold + silver metallics. Restrained, not skeuomorphic. Light: paper #F5EDE0, paperAlt #EDE3D2, ink #2B211A, inkSoft #5A4A3D, mahogany #5C2E23, mahoganyDeep #3E1E17, gold #C0932F, goldSoft #D9B45B, silver #9CA3AF, silverSoft #C7CCD1 Dark: ground #1C1411, surface #241A15, paperText #E8DCC8, mahogany #7A3E2F, gold #D9B45B, silver #C7CCD1 Type: serif display (Literata, OFL, bundle the TTF) for titles/headers; system sans for body/UI. Generous line-height. Motifs: subtle spine/edge treatments, thin gold hairline rules, gentle paper-grain on large surfaces. Covers are the hero — let them carry the color. Both light and dark themes required. Dynamic color OFF (it would fight the palette).

Screens

setup First run: server URL (+ https scheme validation, trailing-slash strip, reachability probe), email, password. Clear errors for wrong URL vs bad creds. library Cover grid (2-3 col adaptive). Search title/author/ISBN. Filter by bookcase/shelf. Sort title/author/added. Empty state invites first scan. FAB -> scan. Sync status line. detail Big cover, title/subtitle/authors/publisher/year/pages/ISBN, description (collapsible), notes (editable), location picker, edit, soft-delete w/ undo. scan Camera + reticle; on hit -> bottom sheet w/ fetched book + shelf picker + Save / Skip. Duplicate-ISBN warning if already owned. locations Bookcases -> shelves tree. CRUD + reorder. Book counts per shelf. Tap a shelf -> library filtered to it. "Move books" bulk action. settings Server, account, sign out, manual sync + last-sync time, book/cover counts.

Quality bar

  • No emulator on this box (no KVM). Verify via: ./gradlew assembleDebug, JVM unit tests, and Paparazzi screenshot rendering.
  • Unit-test the real logic: ISBN checksum, metadata merge, sync conflict resolution, DAO queries (Robolectric). Do not write assertion-free tests.
  • App must compile and run with NO server configured (setup screen) and must not crash when the server is unreachable.