Files
bookshelf/server/README.md
T
Spriteandclaude 0455794603 server: anonymous LIST of users returns 403, not 200 with an empty array
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
2026-09-12 17:47:43 +00:00

117 lines
5.0 KiB
Markdown

# 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`).
```sh
# 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](https://github.com/pocketbase/pocketbase/discussions/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.
```sh
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:
```sh
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.