Files
pak-jabit/docs/pak-jabit-manifest.md
juxtapoandClaude Opus 5 a58a61b98c Pak Jabit: full stack — storefront, Spree customisations, catalogue tooling
A South African men's apparel store on Spree 5.6 + Next.js 15. 160 products
live, three markets, live Malaysian courier rates, customer-owned carts.

What is here:
  storefront/  the Next.js app
  backend/     ONLY the custom Spree code. Spree itself is a prebuilt image
               that mounts no app code, so every file here needs an explicit
               volume entry in docker-compose.yml or it is silently ignored.
  catalogue/   the scripts that loaded and maintain the products
  docs/        manifest, mock registry, napkins

Real .env files are committed deliberately. This lives on the house's private
Gitea, and a clone that runs beats a clone that needs a secrets handover.
LANDMINE.md maps every key, what it opens, and what must be rotated before this
repo goes anywhere less private.

The admin API key used to be a literal in six catalogue scripts. They now read
SPREE_ADMIN_KEY from the environment and refuse to run without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 22:09:55 +08:00

20 KiB
Raw Permalink Blame History

Pak Jabit Store — Project Manifest

🏗️ Architecture

Restructured 2026-09-15: backend and frontend now live on separate VMs, both under jux-lab.com. Vercel is no longer in the path.

shop.jux-lab.com                      api.jux-lab.com + admin.jux-lab.com
(celest-proto, 34.21.217.116)         (pak-jabit-backend, 35.247.170.3)
├── Next.js 16.3.5 storefront         ├── Caddy (SSL)
│   systemd: pak-jabit-store          └── Docker Compose
│   :3200 behind Caddy                    ├── Spree 5.6.1 (Rails 8.1.3)
├── Tailwind 4                            ├── PostgreSQL 18
└── calls api.jux-lab.com                 └── Mailpit

Also on celest-proto (unrelated): jux-lab.com landing, mmo.jux-lab.com, vibein.jux-lab.com, purge relay.

🔑 Credentials & Connections

What Value
Backend VM pak-jabit-backend · asia-southeast1-a · e2-medium · 35.247.170.3
Frontend VM celest-proto · asia-southeast1-a · e2-custom-4-8192 · 34.21.217.116
SSH ssh -i /root/.ssh/google_compute_engine ijat@<ip>
Storefront https://shop.jux-lab.com
Spree Admin https://admin.jux-lab.com (bare host redirects to the panel)
Admin Login juxtapo.opus@gmail.com / ayambasuhkaki
Storefront API https://api.jux-lab.com/api/v3/store/
Admin API https://api.jux-lab.com/api/v3/admin/
Publishable key pk_RVVf1fTUW1T4nW9FTFACm92D (header: X-Spree-Api-Key)
Secret key (admin) sk_ntDFxFE1KwiDwqGzpCBGTJ8N — all scopes
API Media ?expand=media on product endpoint returns full gallery
Store Currency ZAR (switched 2026-09-15). Supported: ZAR, USD, MYR — one spree_markets row per currency; the spree_stores.default_currency COLUMN is ignored, Spree::Stores::Markets overrides the reader
Channel (Online Store) ch_UkLWZg9DAJ
GitHub Repo https://github.com/juxtapo9090/pak-jabit-store (private)
GCloud Project project-19f6479b-f9db-4588-92a
DNS Cloud DNS zone jux-lab-com, same project
Firewall Rule allow-spree (tcp:80,443) — applies to all instances. :3000 closed 2026-09-15; Caddy reaches Spree on 127.0.0.1:3000

🐳 Docker on the backend VM

ssh -i /root/.ssh/google_compute_engine ijat@35.247.170.3
cd ~/pak-jabit
docker compose up -d          # start
docker compose down           # stop
docker logs pak-jabit-web-1   # spree logs
docker exec pak-jabit-web-1 bin/rails runner "..."  # rails console (~30s boot)

⚠️ docker compose restart does NOT re-read .env. Use docker compose up -d after any env change.

🖥️ Storefront on celest-proto

ssh ijat@34.21.217.116
cd ~/pak-jabit-store
npm run build && sudo systemctl restart pak-jabit-store
sudo journalctl -u pak-jabit-store -f

⚠️ Delete .env.local before building — Next.js gives it precedence over .env.production, and the Vercel-era copy points at the old backend.

📦 Products

  • 160 products live (30 original + 130 imported 2026-09-15)
  • Scraped from Zalora (source prices are MYR), 500+ images in ActiveStorage
  • Priced from source at the live rate 2026-09-15: ZAR R99.26-R3185.09 · USD $6.11-$196.02 · MYR RM24.90-RM799.00
  • Imported via the Spree Admin API v3 (scraped-catalogue/bulk_import.py)
  • Brands: G-Shock, H&M, Lacoste, Tommy Hilfiger, Cotton On, GAP, Giordano, Superdry, Under Armour, Calvin Klein, Camel Active, Nike, Urban Revivo, Trendyol, Swiss Polo, and more

🔴 Five gates a product must pass to appear on the storefront

Learned the hard way twice — 130 products invisible on 2026-09-15 morning, and again that evening when Image Studio created products that looked perfect and showed nothing. The scope is Spree::Product.available(Time.current, currency), defined in spree_core-5.6.1/app/models/concerns/spree/product_scopes.rb:315. Read it rather than guessing; every gate below is a clause in it.

  1. status: 'active' and not discontinued.
  2. A price row in the store currency. POST /products silently ignores a scalar "price". It needs "prices": [{"amount": N, "currency": "ZAR"}]. The clause is with_currency(currency), which joins variants_including_master: :prices — no row in that exact currency, no product.
  3. A Spree::ProductPublication row for the channel. Visibility is per-channel, not per-product: for_channel INNER JOINs spree_product_publications, so a product with no publication row is dropped whatever its status. Channels here are 1 Online Store and 2 Wholesale. Creating a product in Rails does not create this row.
  4. Purchasable. Either stock on hand, or track_inventory: false on the master plus a stock item per active location. track_inventory: false alone is not enough — with zero stock items in_stock? is false, purchasable? is false, and the product is excluded.
  5. Timestamps at or before cutoff.beginning_of_minute. The scope truncates the cutoff DOWN to the minute, so published_at = Time.current reads as the future and the product stays hidden until the next minute ticks over. Set Time.current.beginning_of_minute or it looks broken for up to 59 seconds and then mysteriously fixes itself.

Spree::Admin::ImageStudioController#build_new_product does all five and then re-checks available? before reporting success — copy that shape.

🎨 Design Tokens (THANDO)

Token Value
ink #0E0D0B
ochre #C8873A
bone #EDE6DA
bone-light #F5F1EB
ochre-dark #A86E2D
Font Instrument Sans (Google Fonts)
Default theme Dark

☁️ NextCloudPi (Team File Sharing)

What Value
URL https://cloudmi/
WebDAV https://cloudmi/remote.php/dav/files/juxtapo/
User juxtapo
App Password Bk7qS-Pe5zk-7gWoY-kpc6E-KWYky
Box C-fu's mirah (192.168.1.22)

🚚 EasyParcel (MY Shipping)

What Value
Developer Hub https://developer.easyparcel.com
App Name Pak Jabit Store
Client ID 5be15621-f711-485b-bc79-15256747ce65
Client Secret 9a1e611e-c4e2-4d1a-87c8-f695c1323e63
Account Muhammad Aizat (#10935549)
Auth OAuth 2.0
API Docs https://developer.easyparcel.com (Open API Documentation)
Sandbox Available (connect from app config page)
Redirect URI https://shop.jux-lab.com/api/easyparcel/callback — must match the developer hub exactly
Token store ~/pak-jabit-store/.easyparcel-token.json (chmod 600)
Sender address STORE_POSTCODE=10150 / STORE_STATE=MY-07 (Penang) in .env.production
Coverage All 16 MY states verified live — 20 couriers peninsular, 14-15 Sabah/Sarawak
Re-auth Open the callback URL in a browser; EasyParcel asks for login, then writes the token

EasyParcel token handling (fixed 2026-09-15)

The access token lives 10 hours and cannot be extended — that is EasyParcel's server-side policy, and it does not need extending: getToken() renews it with the refresh token automatically, and writes the rotated refresh token back.

🔴 The original code kept the token in a module-level variable, so every restart, redeploy or serverless cold start dropped it and the next quote died with "run OAuth flow first". On Vercel it would have looked random. Now persisted to disk — verified by restarting the service mid-session and getting 20 live quotes straight after.

🔴 req.nextUrl.origin is wrong behind Caddy — it resolved to localhost:3200 and produced a redirect URI EasyParcel would reject. Use APP_BASE_URL instead.

⚠️ Known Issues / TODO

  • Currency USD → ZAR done 2026-09-15, all 160 products in ZAR/USD/MYR
  • Payment gateway not wired (Payfast — see napkin-payment-courier.md)
  • Courier: EasyParcel is in for MY; Bob Go still needed for SA
  • No product variants (size/colour) — every product is master-only
  • track_inventory: false on all products — fine for demo, not for real stock
  • Admin panel has no lockout or rate limiting — Devise lockable is off and there is no rack-attack, so login attempts are unlimited. Worth an IP allowlist or basic auth on the admin.jux-lab.com Caddy block before this goes anywhere real
  • Old Spree containers still on celest-proto (stopped, volumes intact) — delete once the new backend has proven itself for a few days
  • No backups on the backend VM — daily snapshots exist for celest-proto only

🗺️ Planned — customer data & image pipeline

Customer account data (UI only — the data layer already exists)

Spree ships this; we just haven't surfaced it in the storefront.

Need Where it already lives
Saved addresses spree_addresses · GET/POST/PATCH/DELETE /api/v3/store/customers/me/addresses
Saved cards spree_credit_cards · GET /api/v3/store/customers/me/credit_cards

🔴 Never store raw card data. spree_credit_cards holds last_digits, cc_type, month/year, and gateway_customer_id / gateway_payment_profile_id — a gateway token, not the PAN, and no CVV column at all. That is the PCI-correct design and it is already correct. Cards only populate once a gateway (Payfast) is wired, so this sequences after payment.

Photoroom batch pipeline

✅ Built 2026-09-15 evening. Two ways in, same pipe:

  • Image Studio — custom Spree admin page at /admin/image_studio. Upload photos from the device (multiple, accept="image/*" so a phone opens the camera roll) → Photoroom → before/after review → attach to an existing product or create a new listing. Nothing touches the catalogue until Apply; discarding is just navigating away, since the result is held as an unattached blob.
  • CLI — scraped-catalogue/photoroom_to_spree.py --slug <slug> [--live] [--position 1]. Same pipe, scriptable, with a read-back check.

🔴 Batch is NOT built. One at a time, capped at MAX_PHOTOS = 8 per request because each image is a 3-5s synchronous round trip and more starts risking the web server timeout. A real batch needs a background job.

Keys live at /root/.photoroom/credentials (chmod 600) on the house box, and PHOTOROOM_API_KEY in the backend .env. Currently the sandbox key — 1000/month free, every output watermarked. The live key has 10 images/month on the free tier (9 left as of 2026-09-15); the 7-day trial is the web app, a separate meter from API credits, and its Batch tool does 250 images at once through the browser without touching API quota.

⚠️ Photoroom adds little to the scraped catalogue — those images are already studio shots on white, and the only real inconsistency is framing (2 aspect ratios, 4 sizes across 160 products). Its value is phone photos of new stock, which is what Image Studio is for. A free Pillow square-and-pad would fix the existing 160.

📜 Session Log — 2026-09-14 (midnight build)

  1. Discovered old spree-app VM deleted, celest-proto available with jux-lab.com
  2. Installed Docker, cloned spree_starter, docker compose up
  3. Created admin user, loaded 30 products via Rails script
  4. Uploaded 76 product images to Spree ActiveStorage
  5. Published products to "Online Store" channel (Spree::ProductPublication)
  6. Scaffolded Next.js storefront with THANDO design tokens
  7. Wired storefront to Spree API v3 (X-Spree-Api-Key, ?expand=media)
  8. Deployed to Vercel (pak-jabit-store-juxtapo9090.vercel.app)
  9. Pushed to GitHub (juxtapo9090/pak-jabit-store)
  10. Luc found ?expand=media param for full image gallery
  11. Built Zalora-style vertical thumbnail gallery (ProductImages component)
  12. Hardcoded dark theme as default

📜 Session Log — 2026-09-15 (restructure)

  1. Found the "deleted" spree-app VM alive in the jux-mmo project — stopped, then deleted it. Its admin key never matched because each Spree instance HMACs tokens with its own SECRET_KEY_BASE.
  2. Imported 130 products via the Spree Admin API v3 (proper API, not Rails runner — the presentation story is "automated import pipeline").
  3. Spent an hour on 130 invisible products. Three stacked causes, all silent: no published_at, not purchasable, and no price row at all.
  4. Built pak-jabit-backend VM, migrated Postgres + 207MB ActiveStorage. Kept SECRET_KEY_BASE so API keys and image URLs stayed valid.
  5. Split the domain: api + admin → backend VM, shop → celest-proto.
  6. Moved the storefront off Vercel onto celest-proto (systemd + Caddy). A leftover .env.local from Vercel silently built it against the old backend twice before we caught it.
  7. Deleted the Vercel project. One project, two VMs, one domain.
  8. Added admin.jux-lab.com — panel split from the API by hostname, so it can be locked down separately later. Tested in a real browser: no bounce back to api.
  9. Security pass: admin email off spree@example.com, password off spree123, and closed :3000 at the firewall (it was open to the internet over plain HTTP, bypassing Caddy entirely).
  10. Cart proven end-to-end on the new stack: create → add 2x item → $5,154.24.

📜 Session Log — 2026-09-15 (evening: ZAR, currency switcher, Image Studio, fail2ban)

  1. Backups — Projects/PeakyBuildr/backup-pak-jabit.sh, nightly 04:30 via pak-jabit-backup.timer on the house box → /root/Backups/pak-jabit. pg_dump + ActiveStorage rsync + .env + compose + the bind-mounted custom code (none of which is in the database). Restore proven into a throwaway PG18 cluster: 160 products, 3200 blobs, 0 blobs missing their file. RESTORE.md sits beside the dumps.
  2. The 23x price bug — fix_prices.py:6 set MYR_TO_USD = 4.8 and multiplied. 4.8 is the USD→MYR rate; MYR→USD is a divide. Every product was listed at 4.8² = 23.04x its real price (a MYR 537 watch at $2,577). Repriced all 160 from source at the live rate.
  3. USD → ZAR — store now quotes rand. 160 rows each in ZAR/USD/MYR.
  4. Currency switcher on the storefront (ZAR · USD · MYR), URL-param driven so the server fetches real Spree price rows — nothing converted in the browser. The cart deliberately ignores it and stays on the store default.
  5. Image Studio — custom admin page, see the Photoroom section above.
  6. fail2ban — three jails, see Security below.

🔒 Security (2026-09-15 evening)

fail2ban, enabled at boot on the backend VM, banning at iptables so offenders never reach Caddy or Rails:

Jail Trigger Ban
caddy-admin-login 8 POSTs to /admin_users/sign_in in 10 min 24h
caddy-scan 25 × 4xx in 10 min 1h
sshd 5 failed SSH in 10 min 24h

ignoreip = 127.0.0.1/8 ::1 34.21.217.116 27.125.248.0/24 — celest-proto must never be banned (it pulls product images constantly; banning it takes the shop down), and the /24 is abang's ISP block, which his public IP rotates inside. An allports ban on that range would also kill SSH to the box.

Caddy has no rate_limit module in the standard build, which is why this is fail2ban and not a Caddy directive.

🔴 Caddy had no access log until tonight — errors only, so successful requests left no trace whatsoever. Now JSON to journald (a file under /var/log/caddy/ fails to open even with the directory writable, and Caddy fails the whole service on start rather than skipping the log — it dropped the site for about a minute finding that out).

⚠️ Unresolved: 42.188.122.106 (Windows, Brave 153, carrying a session cookie) requested /admin/api_keys, /admin/store/edit and /admin/admin_users. Abang's browser is Linux on 27.125.248.0/24 — different machine. Those specific requests 502'd mid-restart, but with no access logs before tonight any earlier success is invisible. Most likely a colleague, since abang expects colleagues to have access. sign_in_count is 0 but Devise trackable looks disabled, so that number proves nothing.

🔴 There is exactly ONE admin account, shared. Every session is indistinguishable, which is precisely why the above cannot be attributed. Give each person their own account (Settings → Users) before more people get access.

💣 Landmines hit today

Every one of these returned success while being wrong. Worth knowing before touching this stack again.

Landmine What it looked like The actual cause
Scalar price is silently dropped POST /products returns 200, product exists, price is null Spree wants prices: [{amount, currency}]. No price row in the store currency → excluded from available(Time.current, currency) → invisible on the storefront, no error anywhere
published_at left null add_products returns {product_count: 130} The publication row is created but not live unless you pass published_at
Not purchasable Product active, available, published No stock and track_inventory: true. Needs stock or track_inventory: false
.env.local beats .env.production Build succeeds, only 30 of 160 pages Next.js precedence. The Vercel-era file pointed at the old backend, which was still running and still serving 30 products — so the build read stale data and reported nothing wrong. Cost two full rebuilds
docker compose restart ignores .env Env edited, behaviour unchanged restart reuses the existing container config. Needs up -d
Admin API key looked broken find_by_secret_token → no match, every time The key was copied from a different Spree instance. Each one HMACs tokens with its own SECRET_KEY_BASE
"Deleted" VM was still running Manifest said deleted; nothing in the project list It was in a different GCP project (jux-mmo), still billing
OAuth token in a module variable Shipping worked, then randomly didn't Process memory dies on restart/redeploy/cold start. Persist the refresh token to disk or DB
req.nextUrl.origin behind a proxy OAuth redirect_uri came out as localhost:3200 Next.js sees the internal listener, not the public host. Use an explicit APP_BASE_URL
100KB API body cap Image upload → request_too_large on a 115KB jpg Spree::Api::Config[:max_request_body_size] default. 🔴 The fix recorded here was never live. config/initializers/api_body_limit.rb existed on the host with the 10MB value but the container never loaded it — the compose file mounts no app code, so the file was dead. Fixed by bind-mounting it (2026-09-15 evening). A runtime assignment also does nothing, but not for the reason written before: it isn't read at boot, it just isn't persisted, so it only affects the process that ran it
Prebuilt image mounts no app code Initializer written, never ran ghcr.io/spree/spree ships its own /rails. Anything custom needs an explicit bind mount in docker-compose.yml. This silently killed the 10MB body-limit fix for a whole day
add_routes from an initializer is a no-op Sidebar entry appears, path helper does not exist Spree draws engine routes BEFORE config/initializers run. Routes must go in config/routes.rb
New asset defaults to position 1 Appended an image, it became the product thumbnail Spree defaults position to 1. A watermarked sandbox image went live as a public thumbnail this way. Always send an explicit position
max(:position) on media misses half the gallery Appended image landed mid-gallery at position 2 A gallery is the UNION of product-level media and legacy assets on the master variant. Clear both
product.stores << store NoMethodError on a Relation Products belongs_to :store (singular) in 5.6. Use store: on create
published_at = Time.current hides the product Created, active, priced, stocked — invisible for up to 59s The scope compares against cutoff.beginning_of_minute, truncating DOWN. Use Time.current.beginning_of_minute
curl -o loses binary bodies here HTTP 200, content-length: 758983, zero-byte file The rtk proxy layer eats it. Use Python for binary downloads on the house box

Full write-up: /mnt/playground/Server_files/Debug-Logs/2026-09-15_celeste_pak-jabit-restructure-and-invisible-products.md