Baseline: wave 1A server complete, wave 1B Android scaffold + design system green

assembleDebug, testDebugUnitTest, and recordPaparazziDebug all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bThmkmyUUdqQpy3MXFFe5
This commit is contained in:
2026-09-06 01:58:37 +00:00
commit 6c17e42037
79 changed files with 3773 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# Bookshelf PocketBase server — container image.
#
# The pocketbase binary is already vendored into ./server (this repo does not
# gitignore it out of the image build context — see server/.gitignore, which
# only excludes it from *git*). If you'd rather fetch it fresh, replace the
# COPY below with a curl of the official release for your architecture from
# https://github.com/pocketbase/pocketbase/releases (match the version
# already pinned in server/pocketbase --version, currently v0.40.2).
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /pb
COPY pocketbase /pb/pocketbase
COPY pb_migrations /pb/pb_migrations
COPY pb_hooks /pb/pb_hooks
RUN chmod +x /pb/pocketbase
# pb_data is a volume — see docker-compose.yml. Do not bake data into the
# image; it must survive container recreation.
VOLUME /pb/pb_data
EXPOSE 8090
ENTRYPOINT ["/pb/pocketbase"]
CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations", "--hooksDir=/pb/pb_hooks"]
+156
View File
@@ -0,0 +1,156 @@
# Deploying Bookshelf's PocketBase server
This assumes a spare always-on machine at home (a mini PC, NUC, Raspberry Pi,
or an old laptop) running Linux. Pick **one** of the two run methods below —
systemd or Docker — not both.
## 0. Get the files onto the server
Copy the whole `server/` directory (minus `.dev-credentials`, which is
gitignored and dev-only) to the target machine, e.g.:
```sh
rsync -av --exclude .dev-credentials ~/bookshelf/server/ youruser@homeserver:/opt/bookshelf/
```
The `pocketbase` binary is architecture-specific — if your home server isn't
the same CPU architecture as wherever you built/downloaded it, grab the
matching build from https://github.com/pocketbase/pocketbase/releases
(this project is pinned to **v0.40.2**) and drop it in as `/opt/bookshelf/pocketbase`.
## 1. Run it — Option A: systemd (recommended for a bare-metal/VM host)
1. Create a dedicated unprivileged user:
```sh
sudo useradd --system --home /opt/bookshelf --shell /usr/sbin/nologin bookshelf
sudo chown -R bookshelf:bookshelf /opt/bookshelf
```
2. Install the unit:
```sh
sudo cp deploy/bookshelf.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now bookshelf
```
3. Check it's up: `systemctl status bookshelf` and `curl http://127.0.0.1:8090/api/health`.
4. Logs: `journalctl -u bookshelf -f`.
Note the unit binds PocketBase to `127.0.0.1:8090` only — it is **not**
reachable from other machines yet. That's intentional; see step 4.
## 2. Run it — Option B: Docker
```sh
cd deploy
docker compose up -d --build
docker compose logs -f
```
This also only publishes to `127.0.0.1:8090` on the host, for the same
reason. Data persists in the `bookshelf_pb_data` named volume regardless of
container restarts/rebuilds.
## 3. Provision the schema and accounts
Once the server is up and answering on `127.0.0.1:8090`, run the setup
scripts from `server/` (one directory up from here):
```sh
cd /opt/bookshelf
# If this is a brand-new PocketBase data dir, it prints a one-time setup URL
# on first launch (see `journalctl -u bookshelf` or `docker compose logs`) —
# open that in a browser first to create your superuser account.
./setup-schema.sh http://127.0.0.1:8090 <superuser-email> <superuser-password>
# Then create the household accounts — there is no public sign-up:
./create-user.sh owner@example.com "a strong password" "Owner Name"
./create-user.sh spouse@example.com "a different strong password" "Spouse Name"
```
`setup-schema.sh` is idempotent — safe to re-run any time (e.g. after
pulling an updated `server/` if the schema ever changes).
## 4. Reach it from outside your home network
The app needs a URL it can hit from anywhere your phone goes — not just your
home wifi. Two real options:
### Tailscale (recommended)
Install Tailscale on the home server and on your phone, join both to the
same tailnet. The server gets a stable `100.x.y.z` address (or a
MagicDNS name like `homeserver.your-tailnet.ts.net`) reachable from
anywhere, encrypted end-to-end, with **no ports opened on your router**.
- **Pros:** essentially zero attack surface (nothing is exposed to the
public internet at all — not even a login page), no TLS cert management,
works behind CGNAT, five-minute setup.
- **Cons:** both devices need the Tailscale app installed and signed in;
if Tailscale's coordination service has an outage, *new* connections may
be briefly unable to establish (existing ones keep working) — acceptable
for a two-person home library, not for something needing five-nines.
- Point the app's server-URL field at `http://100.x.y.z:8090` (Tailscale's
encryption makes plain HTTP tolerable *inside the tailnet*, but see the
HTTPS note below — using `https://` via Tailscale Serve, next, is easy
enough to just do).
- Even better: use [`tailscale serve`](https://tailscale.com/kb/1312/serve)
to get automatic HTTPS with a real cert on your tailnet domain, so the
app can just always use `https://`:
```sh
sudo tailscale serve --bg 8090
```
### Port-forward + Caddy (reverse proxy with TLS)
Forward a port on your router to the home server, and run
[Caddy](https://caddyserver.com/) in front of PocketBase to terminate TLS
with an automatic Let's Encrypt certificate.
- **Pros:** works with any client, no extra app/agent needed on the phone,
a real public HTTPS URL.
- **Cons:** you're now running an internet-facing service from your home —
bugs in PocketBase, Caddy, or your router's firmware are now a real
attack surface; you need a domain name (or dynamic-DNS if your ISP gives
you a changing IP) for Let's Encrypt's HTTP-01/TLS-ALPN-01 challenge to
work; residential ISPs sometimes block inbound 80/443 or use CGNAT,
which breaks this approach entirely (Tailscale sidesteps that).
Minimal Caddyfile:
```
bookshelf.yourdomain.com {
reverse_proxy 127.0.0.1:8090
}
```
Caddy handles the certificate automatically. Point your router's port
forward at the Caddy host's 443, and the app's server-URL field at
`https://bookshelf.yourdomain.com`.
### HTTPS is not optional
The app sends the account password on every login. **Never** point the app
at a plain `http://` URL that crosses the public internet or an untrusted
network — only plain HTTP over Tailscale (which is already
end-to-end-encrypted at the network layer) is acceptable, and even there,
prefer `tailscale serve` for a real cert. If you go the port-forward route,
Caddy above gets you HTTPS for free — don't skip it.
## 5. Back up regularly
```sh
./deploy/backup.sh
```
See the comments at the top of `backup.sh` for what it does (SQLite-safe
snapshot + uploaded covers, tarball, prune old ones) and the restore
procedure. Wire it into cron:
```sh
crontab -e
# add:
0 3 * * * /opt/bookshelf/deploy/backup.sh >> /var/log/bookshelf-backup.log 2>&1
```
Consider also copying the resulting tarballs off-box (another machine, a
USB drive, cloud storage) — a backup that lives on the same disk as the
data it's backing up doesn't protect against disk failure.
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
#
# backup.sh — SQLite-safe backup of a running PocketBase pb_data directory.
#
# PocketBase's data.db and auxiliary.db are SQLite databases opened in WAL
# mode. Copying the .db files directly with `cp` while the server is running
# can grab an inconsistent snapshot if a checkpoint happens mid-copy. Using
# sqlite3's `.backup` command (or PocketBase's own "backups" API) takes a
# consistent snapshot safely without stopping the server.
#
# This script uses `sqlite3 .backup`, which is the simplest option that needs
# no PocketBase superuser credentials. It backs up pb_data (including
# uploaded files) into timestamped tarballs and prunes old ones.
#
# Usage:
# ./backup.sh [PB_DATA_DIR] [BACKUP_DIR]
# PB_DATA_DIR defaults to ../pb_data (relative to this script)
# BACKUP_DIR defaults to ./backups (relative to this script)
#
# Suggested cron (nightly at 3am, keep last 14):
# 0 3 * * * /opt/bookshelf/deploy/backup.sh >> /var/log/bookshelf-backup.log 2>&1
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PB_DATA_DIR="${1:-$SCRIPT_DIR/../pb_data}"
BACKUP_DIR="${2:-$SCRIPT_DIR/backups}"
KEEP_LAST="${KEEP_LAST:-14}"
log() { printf '[%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*"; }
fail() { log "ERROR: $*"; exit 1; }
command -v sqlite3 >/dev/null 2>&1 || fail "sqlite3 is required (apt install sqlite3 / apk add sqlite)."
[[ -d "$PB_DATA_DIR" ]] || fail "pb_data directory not found: $PB_DATA_DIR"
TIMESTAMP="$(date -u '+%Y%m%d_%H%M%S')"
WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT
mkdir -p "$BACKUP_DIR"
log "Backing up pb_data from $PB_DATA_DIR"
# Consistent SQLite snapshots via the .backup command (safe for a live,
# WAL-mode database — it's the same mechanism `sqlite3 db.sqlite .backup`
# uses, which briefly locks for the copy but never corrupts).
for db in data.db auxiliary.db; do
src="$PB_DATA_DIR/$db"
if [[ -f "$src" ]]; then
log " snapshotting $db"
sqlite3 "$src" ".backup '$WORKDIR/$db'"
fi
done
# Everything else under pb_data that isn't a sqlite file/WAL/SHM (i.e.
# uploaded covers under storage/) gets copied as-is — these are immutable
# blobs once written, so a plain copy is safe.
log " copying uploaded files (storage/)"
if [[ -d "$PB_DATA_DIR/storage" ]]; then
cp -a "$PB_DATA_DIR/storage" "$WORKDIR/storage"
fi
ARCHIVE="$BACKUP_DIR/bookshelf-backup-$TIMESTAMP.tar.gz"
tar -czf "$ARCHIVE" -C "$WORKDIR" .
log "Wrote $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))"
# Prune old backups, keep the most recent $KEEP_LAST.
mapfile -t old < <(ls -1t "$BACKUP_DIR"/bookshelf-backup-*.tar.gz 2>/dev/null | tail -n +$((KEEP_LAST + 1)))
if [[ ${#old[@]} -gt 0 ]]; then
log "Pruning ${#old[@]} backup(s) older than the last $KEEP_LAST"
rm -f "${old[@]}"
fi
log "Done."
# --- Restore ---------------------------------------------------------------
# 1. Stop the server: sudo systemctl stop bookshelf
# 2. Move aside the live data: mv /opt/bookshelf/pb_data /opt/bookshelf/pb_data.bak
# 3. Extract the chosen backup: mkdir /opt/bookshelf/pb_data && \
# tar -xzf bookshelf-backup-TIMESTAMP.tar.gz -C /opt/bookshelf/pb_data
# 4. Fix ownership: sudo chown -R bookshelf:bookshelf /opt/bookshelf/pb_data
# 5. Start the server again: sudo systemctl start bookshelf
# 6. Once you've confirmed it's healthy, delete pb_data.bak.
+36
View File
@@ -0,0 +1,36 @@
[Unit]
Description=Bookshelf PocketBase server
After=network-online.target
Wants=network-online.target
[Service]
# Adjust to your setup: a dedicated, unprivileged system account.
# Create it once with: sudo useradd --system --home /opt/bookshelf --shell /usr/sbin/nologin bookshelf
User=bookshelf
Group=bookshelf
# All paths below assume the whole ~/bookshelf/server tree (pocketbase binary,
# pb_data/, pb_migrations/, pb_hooks/) is deployed to /opt/bookshelf.
# Adjust WorkingDirectory/ExecStart if you deploy elsewhere.
WorkingDirectory=/opt/bookshelf
ExecStart=/opt/bookshelf/pocketbase serve \
--http=127.0.0.1:8090 \
--dir=/opt/bookshelf/pb_data \
--migrationsDir=/opt/bookshelf/pb_migrations \
--hooksDir=/opt/bookshelf/pb_hooks
Restart=always
RestartSec=5
# Hardening (safe defaults for a single-purpose service; loosen only if
# something concrete breaks).
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/bookshelf/pb_data
CapabilityBoundingSet=
AmbientCapabilities=
[Install]
WantedBy=multi-user.target
+34
View File
@@ -0,0 +1,34 @@
# Bookshelf PocketBase server — Docker Compose deployment.
#
# Build context is the parent `server/` directory (so the Dockerfile can see
# pocketbase, pb_migrations/, pb_hooks/). Run compose from server/deploy/:
#
# cd server/deploy && docker compose up -d
#
# Data lives in the named volume `bookshelf_pb_data`, independent of the
# container lifecycle — `docker compose down` (without -v) never touches it.
#
# The port is published on 127.0.0.1 only, matching bookshelf.service: this
# container is meant to sit behind Tailscale or a Caddy reverse proxy on the
# same host, never exposed directly to the internet (see deploy/README.md).
services:
pocketbase:
build:
context: ..
dockerfile: deploy/Dockerfile
container_name: bookshelf-pocketbase
restart: unless-stopped
ports:
- "127.0.0.1:8090:8090"
volumes:
- bookshelf_pb_data:/pb/pb_data
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8090/api/health"]
interval: 30s
timeout: 5s
retries: 3
volumes:
bookshelf_pb_data:
name: bookshelf_pb_data