Files
bookstore/README.md
T
twooeyandClaude Sonnet 5 8b149a672d Restrict production wp-admin/wp-login to an IP allowlist (opt-in)
New WP_ADMIN_ALLOWED_IPS env var (space-separated CIDR ranges, matching
Caddy's remote_ip syntax directly). Defaults to 0.0.0.0/0 ::/0 (today's
open behavior) when unset, resolved at the Compose level rather than in
the Caddyfile so an unset var can never leave `remote_ip` with zero
arguments and fail Caddy's startup. admin-ajax.php stays exempt since
WooCommerce routes logged-out storefront AJAX through it. Production
only — staging is already LAN-only.

Verified live: a scratch Caddy instance on a Docker network, with a
sibling container's actual observed source IP used to prove both the
blocked (403 on wp-admin/wp-login, everything else unaffected) and
allowed (matching CIDR lets it through) cases, plus `docker compose
config` to confirm the unset-var default resolves correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MqhgnCRjjVLFnZ3Jpn93Vg
2026-08-28 11:15:55 -04:00

11 KiB
Raw Blame History

Bookstore

WordPress + WooCommerce bookstore. Design reference: see docs/ for the pre-launch plan and the technical design doc (bookstore-core schema, interfaces, order state machine — the design doc is the source of truth for why this repo is laid out the way it is).

Weeks 1–2 of the 5-week plan are done: environments, deploy pipeline, the WooCommerce/HPOS plugin scaffold, catalog schema, Gutenberg importer, pricing engine, and membership shipping all live under wp-content/plugins/bookstore-core/includes/. Checkout, the order state machine, and live supplier integrations are Week 3+.

Layout

docker-compose.yml              base services: db, redis, wordpress, cron
docker-compose.{dev,staging,production}.yml   per-environment overrides (caddy, mailhog)
docker/php/                     app image: WP core image + wp-cli, composer, redis ext
docker/caddy/                   one Caddyfile per environment
docker/cron/                    Action Scheduler driver (system cron has no host to run on in Docker)
deploy/deploy.sh                idempotent bring-up + WP/WooCommerce config
deploy/poll-deploy.sh           host-crontab script: redeploys when its branch moves on Gitea
deploy/backup.sh, restore.sh    off-host backup; restore is staging-only, on purpose
secrets/<env>/                  DB passwords, written fresh by deploy.sh — gitignored, not manually edited
wp-content/plugins/bookstore-core/   the one plugin that owns business logic
wp-content/mu-plugins/          local-mail-catcher.php — routes mail to MailHog outside production

Same code runs in every environment; only .env.<env> and which docker-compose.<env>.yml you layer in differ (design doc §01).

Local development

cp .env.example .env.dev        # fill in DB_PASSWORD etc.; localhost values are fine
make up ENV=dev
make deploy ENV=dev             # installs WP, WooCommerce, activates bookstore-core, enables HPOS

Site is at http://localhost:8080. make wp ENV=dev ARGS="plugin list" runs any wp-cli command; make logs ENV=dev tails everything; make shell ENV=dev drops into the app container.

Staging (this is what goes on your Docker server)

cp .env.example .env.staging
# fill in SITE_DOMAIN, SITE_URL, DB_*
# SITE_DOMAIN must be a HOSTNAME, not a bare IP — see below

make deploy ENV=staging

SITE_DOMAIN needs to be a hostname, not the VM's IP directly. TLS SNI (how Caddy picks which certificate to present) isn't sent for literal IP connections per spec, so HTTPS to a bare IP fails at the handshake itself no matter what Caddy does. Pick any hostname (e.g. bookstore.lan), add it to /etc/hosts (or the Windows equivalent) on whatever machine you're browsing from, pointing at the VM's LAN IP, and use that hostname as both SITE_DOMAIN and in SITE_URL.

Caddyfile.staging forces tls internal — Caddy's own self-signed CA, issued locally with no external network calls — rather than letting Caddy guess whether the name looks "public" (its automatic heuristic only recognizes bare IPs and localhost as obviously-private; anything else, including a made-up LAN hostname, it assumes might be real and tries Let's Encrypt, which then fails). Your browser will show an untrusted-cert warning once; click through it, or trust Caddy's root cert to skip that:

docker compose -p bookstore-staging exec caddy cat /data/caddy/pki/authorities/local/root.crt

Staging is noindex'd (blog_public=0 plus the X-Robots-Tag header in Caddyfile.staging) so search engines won't index it — there's no basic-auth wall on top of that, since this box is LAN-only and not reachable from outside. If that ever changes (a public domain, port-forwarding, etc.), basic-auth is worth adding back before that happens, not after. Mail never leaves the box: it's caught by MailHog, viewable at :8025.

Theme: Blocksy + the Book Store starter site

deploy.sh installs and activates the Blocksy theme and the free Blocksy Companion plugin from wordpress.org automatically — nothing to do here, in any environment.

The Book Store starter site itself is a paid Companion Pro template (Business plan, $99/year — design doc Appendix A), so it can't be scripted against a public API the way the free theme can. One-time manual step per environment, in wp-admin:

  1. Purchase/retrieve the Blocksy Business license key.
  2. Blocksy → General → License → activate it.
  3. Blocksy → Extensions → Starter Sites (Companion Pro) → import Book Store.

After that, the starter site's content and Customizer settings persist in the database like any other WordPress content — a redeploy or a fresh deploy.sh run doesn't touch it or need to repeat it.

Payments: Helcim

deploy.sh installs and activates the free, wordpress.org-hosted Helcim for WooCommerce plugin (helcim-commerce-for-woocommerce) automatically in every environment — nothing to do here for that part.

The plugin needs a Helcim sandbox API token, which isn't something a public API can hand out, so it's a one-time manual step per environment:

  1. Sign up for a free Helcim account.
  2. Helcim dashboard → Integrations → API Configuration → create an API token.
  3. Put it in HELCIM_API_TOKEN in .env.<environment>, then re-run make deploy ENV=<environment>.

deploy.sh writes the token to a gitignored secrets file the same way it already does for HARDCOVER_API_TOKEN (see secrets/<env>/ above), and Payments\HelcimConfig::ensure_configured_from_env() seeds the gateway's WooCommerce settings (sandbox mode, disabled by default — a human still enables it at checkout once ready) the first time it finds a token. Until a token exists, the plugin is installed and inert; nothing else in this repo is blocked on it. Go-live (switching the gateway from sandbox to Helcim's live environment) is a deliberate manual step on the settings screen, not something this repo automates.

Admin access restriction (production)

/wp-admin and /wp-login.php can be restricted to specific IPs/CIDR ranges in production via WP_ADMIN_ALLOWED_IPS in .env.production — space-separated, matching Caddy's own remote_ip syntax directly (e.g. 203.0.113.5 198.51.100.0/24). Leave it blank and wp-admin stays reachable from anywhere, same as today — this is opt-in, not on by default, and only applies in production (staging is already LAN-only). /wp-admin/admin-ajax.php is always exempt regardless of the IP list, since WordPress/WooCommerce route some logged-out storefront AJAX through it. Applying a changed value just needs make deploy ENV=production (or restarting the caddy container) — no code change required.

Deploy pipeline

The Git server (Gitea) and the staging/production Docker hosts are separate machines, so instead of a webhook receiver listening for an inbound POST from Gitea, each box just polls its branch and redeploys when it moves — no inbound port to expose or secure.

  1. Clone this repo onto the box (e.g. into /srv/bookstore), cp .env.example .env.staging (or .env.production) and fill it in.
  2. Add a host crontab entry (not inside a container — crontab -e on the box itself):
    */2 * * * * /srv/bookstore/deploy/poll-deploy.sh staging >> /var/log/bookstore-deploy.log 2>&1
    
    (production + main on the production box.)
  3. poll-deploy.sh fetches, compares against the last-deployed commit, and — only if it moved — checks out the branch and runs deploy.sh. Silent otherwise, so it's safe to run every couple of minutes.
  4. The box needs its own git credentials to fetch from Gitea (a read-only access token works well) — set those up once via a credential helper or an embedded token in that box's origin remote.

You can also just run ./deploy/deploy.sh <env> by hand at any time; the poll script is only automation on top of the same idempotent script.

Backups

./deploy/backup.sh staging       # or production

Dumps the database and archives wp-content/uploads, gzips both, and syncs to $BACKUP_REMOTE via rclone if set (configure rclone config on the server first — this repo doesn't manage remote credentials). Before launch, run a real restore drill:

./deploy/restore.sh ./backups/production/db-<ts>.sql.gz ./backups/production/uploads-<ts>.tar.gz

restore.sh only ever targets staging — there's no production argument, so a restore drill can't accidentally overwrite the live site.

Catalog pipeline (Week 2)

Staging data is built with wp bookstore sub-commands (see wp bookstore help for full options):

wp bookstore import-gutenberg --limit=10000   # Work/Edition/ISBN rows from the real PG catalog feed
wp bookstore generate-offers                  # synthetic SupplierOffer rows: stock, condition, price
wp bookstore sync-products                    # one WooCommerce product per Work
wp bookstore sync-covers --limit=20           # optional: real cover art via Open Library
wp bookstore sync-hardcover-tags --limit=20   # optional: genre/mood/content-warning tags

import-gutenberg --limit counts only new titles per run, so re-running with a bigger --limit continues forward instead of rescanning. Pricing (25% target margin) and the $25/$15 free-member shipping thresholds are in includes/Pricing/PricingEngine.php and includes/Shipping/BSC_Free_Shipping.php.

Checkout + order engine (Week 3)

Helcim's WooCommerce plugin is installed (see "Payments: Helcim" above), paired with a synchronous pre-charge supplier/margin recheck and a fraud-check hook — both run right before the payment gateway is called (Order\CheckoutRecheck), never after. A passing order moves through a custom status sequence beyond WooCommerce's own pending/processing/ completed: bsc-ordered (submitted to the supplier via Action Scheduler, Order\SupplierOrderJob) → bsc-shipped → completed. Any failure at any stage — no sellable offer, margin no longer holds, fraud check fails, or the supplier submission itself fails after charging — lands the order in bsc-exception ("Needs Review"), which is the review queue: just a filterable WooCommerce order status, not a separate table. A post-charge failure also triggers an automatic refund (wc_create_refund(..., refund_payment: true)), per the design doc's Appendix B decision that an undo is always a refund, never a void.

wp bookstore force-offer-status <isbn13> <active|stale|discontinued> (see wp bookstore help) forces a specific ISBN's synthetic offer into a specific state — SyntheticOfferGenerator's rolls are random, so this is how to reliably exercise the recheck's failure paths (price-increase, out-of-stock, discontinued-after-charge) in a real checkout.

Real BooksRun/Ingram supplier adapters are still Week 4 — SupplierOrderJob runs against the synthetic gutenberg_test supplier for now, deliberately built through the same Action Scheduler/locking path Week 4's real integration will reuse rather than restructure.

What's deliberately not here yet

  • Live BooksRun/Ingram supplier adapters — Week 4, see the design doc.
  • Automated tests for the pricing/shipping-threshold/checkout-recheck logic — called for by the design doc's launch gates, not yet written.
  • A real TLS-terminating public domain — Caddy will auto-provision certs once SITE_DOMAIN points at a real host with 80/443 reachable; until then docker-compose.dev.yml is the only one that works from localhost.
  • CI test/lint automation — nothing here runs tests yet because there's no application code to test yet.