Found while re-verifying the security posture against the public hostname now that the server is internet-exposed. pb_hooks/main.pb.js covered bookcases, shelves and books but never users, so an anonymous GET of the accounts collection answered 200 with an empty array. Nothing leaked: two real accounts exist and the declarative listRule filtered both out, so no account, email or id was ever visible to an anonymous caller. But "200 with []" is the exact wrong signal this hook exists to remove — some clients read it as an allowed request — and the accounts collection is the last place to leave it. Defensible while the server was localhost-only; not now. Re-verified over the internet after restarting the service: books, shelves, bookcases and users all 403 anonymous, self-registration 403, health 200. Also re-verified that login still works, since this hook now runs on a collection the app authenticates against: auth-with-password returns 200 with a token, and an authenticated LIST of all four collections still returns 200. The hook rejects unauthenticated list/search only, and auth-with-password is not a list request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPpdG8VnRfS3KkisR3HUAE
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.
Check the accounts collection too — it is covered by the same hook:
curl -s -o /dev/null -w '%{http_code}\n' "$BASE/api/collections/users/records"
# -> 403
# ...but logging in must still work, which is the thing to re-check after any
# change to pb_hooks (the hook rejects anonymous LIST, not authentication):
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$BASE/api/collections/users/auth-with-password" \
-H 'Content-Type: application/json' -d '{"identity":"you@example.com","password":"..."}'
# -> 200
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.