Files
bookshelf/server
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
..

Bookshelf server

PocketBase backend for the Bookshelf app — see ../docs/SPEC.md for the authoritative data model and API rules. This directory contains everything needed to provision and run it.

Layout

server/
  pocketbase          PocketBase v0.40.2 binary (gitignored — platform-specific)
  pb_data/            SQLite databases + uploaded files (gitignored — runtime state)
  pb_migrations/       Schema history, auto-captured — a fresh instance replays
                       these on first boot and ends up with the full schema
  pb_hooks/           JS hook that closes a PocketBase quirk (see below)
  setup-schema.sh     Idempotent schema/rules provisioning (curl + jq only)
  create-user.sh      Superuser-driven account creation (no public sign-up)
  deploy/             systemd unit, Docker, backups, remote-access guidance

Quickstart (local dev)

PocketBase must already be running (see deploy/README.md for how to run it as a proper service; for a quick local check you can just run the binary directly: ./pocketbase serve).

# 1. Provision the schema + API rules (safe to re-run).
./setup-schema.sh http://127.0.0.1:8090 <superuser-email> <superuser-password>

# 2. Create app accounts — there is no self-registration.
./create-user.sh you@example.com "a strong password" "Your Name"

Both scripts also read PB_URL/PB_EMAIL/PB_PASS from the environment, or fall back to .dev-credentials (gitignored, dev-instance-only — see .dev-credentials in this directory if present) if no args are given.

Schema summary

Three collections — bookcases, shelves, books — each requiring authentication for every action (list/view/create/update/delete). Deletion in the app is always a soft deleted flag (tombstone), never an actual record delete, so sync can propagate it — see SyncEngine in the Android app and docs/SPEC.md's Sync design section.

The built-in users collection is locked down: createRule = null means only a superuser can create an account (via create-user.sh); regular users can view any user (needed to resolve the added_by relation) and update only their own record.

The pb_hooks quirk

PocketBase's declarative list/search rule acts as a row-level SQL filter, not a hard gate: an unauthenticated request against a collection whose listRule requires auth still gets 200 OK with an empty result, rather than an error — because the rule can't be cleanly separated into "deny the whole request" vs. "just don't return matching rows" (see pocketbase/pocketbase#6492). No private data ever leaks this way, but a 200 is still the wrong signal for "you're not allowed here." pb_hooks/main.pb.js adds a small onRecordsListRequest hook that turns that into a 403 for the three app collections. It's plain JS, auto-loaded by the stock pocketbase binary — no recompilation, no framework — so it ships and deploys exactly like pb_migrations/.

Verifying a deployment

Run these against the address the outside world uses, not 127.0.0.1. A localhost curl cannot tell you what is reachable from the internet, and the live instance of this server (see below) is on the internet, with these rules as its only protection.

BASE=https://your-server.example        # the PUBLIC base URL, not localhost

# Anonymous requests must be rejected for all three collections:
for c in books shelves bookcases; do
  curl -s -o /dev/null -w "$c %{http_code}\n" "$BASE/api/collections/$c/records"
done
# -> 403, 403, 403

# Self-registration must be rejected:
curl -s -X POST "$BASE/api/collections/users/records" \
  -H 'Content-Type: application/json' -d '{"email":"x@x.com","password":"password123","passwordConfirm":"password123"}'
# -> 403 "Only superusers can perform this action."

A 200 with a non-empty items array from any of those is a breach, and a 200 with an empty one means the pb_hooks guard is not loaded — see the quirk described above.

Known gap: GET /api/collections/users/records currently answers 200 with an empty array rather than 403. Nothing is disclosed — the listRule filters every row out, so an anonymous caller sees no accounts, no emails, no ids — but it is the same wrong-signal quirk as above, and pb_hooks/main.pb.js does not yet cover the users collection. Adding it to that hook's collection list closes it.

Where the live server runs

The instance these phones talk to runs on the development sprite, bound to 0.0.0.0:8090 and published over HTTPS on a sprite-issued hostname, so it is internet-exposed. That is an interim arrangement; deploy/ covers the intended move to home hardware behind Tailscale. The app never hardcodes a server URL — it is entered on the first-run setup screen — so that move needs no code change, only re-pointing each phone.

See deploy/README.md for running the server long-term, reaching it from outside your home network, and backups.