# 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@` | | **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 ```bash 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 ```bash 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 - [x] ~~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 [--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`