Adds customers.disabled_at, admin disable/enable endpoints, a Status
column and toggle on the Customers tab, and enforcement across every
path that authenticates.
Enforcement lives in attachCustomer, which previously validated only the
session token and its expiry and never read the customer row. Register,
login and password reset all mint sessions, so a single check in the
middleware covers every path rather than three separate ones — and it
means an existing rd_session cookie stops working at once instead of at
its 30-day expiry. Disabling also deletes the sessions outright, so
eviction does not wait for the next request.
Disabling releases the items the customer was holding, in the same
transaction. A disabled account cannot check out, so leaving its
reservations would keep one-of-a-kind stock off the storefront for up to
the cart expiry window for no purpose. Guarded on 'reserved' so a sold
item is never resurrected. Re-enabling restores sign-in but does not give
the items back — they may since have sold.
Sign-in returns an explicit 403 rather than a generic credential failure.
That does confirm the address has an account, which sits awkwardly beside
the deliberately non-enumerating reset in #32; the trade was made the
other way because a disabled customer told "invalid email or password"
resets their password, succeeds, is still locked out, and concludes the
site is broken. The check runs only after the password verifies, so it is
not a bulk membership oracle, and /register already reveals existence.
A reset token issued before the disable no longer mints a session, and no
new tokens are issued for a disabled account — while still answering 200,
so that endpoint stays non-enumerating.
Self-service GDPR export and deletion are blocked along with everything
else, so those requests now need servicing by hand. Worth checking the
privacy policy does not promise unconditional self-service.
Also fixes an unrelated bug the e2e run surfaced: the admin inventory
fired a request per keystroke in the price fields with no sequencing, so
an older response could land after a newer one and repaint stale rows.
Only the most recently issued request may now set state.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds "Forgot password?" to the login page, a request page, and a reset
page reached by a one-hour, single-use token delivered by email. Reuses
customer_tokens with a new password_reset kind alongside verify_email.
The request endpoint always answers 200, whether or not the address has
an account, so it cannot be used to test addresses for membership. Note
/register still reveals existence through its 409 on a duplicate, so this
protection is currently partial; closing that is its own change.
Completing a reset deletes every session for that customer. A reset
prompted by a compromise has to evict the intruder, and leaving a 30-day
cookie alive would defeat the point. It also marks the address verified,
since receiving the mail is exactly what verification proves, and
supersedes any outstanding token so an older link in the inbox cannot be
resurrected.
Introduces the first rate limiting in the codebase, on the request
endpoint only. The limiter is keyed on caller *and* submitted address:
keying on IP alone would let one person lock out everyone behind the same
proxy, and everything arrives via Nginx Proxy Manager. Applying that same
limiter to the reset endpoint, which carries no address, collapsed every
caller into one shared bucket -- so that endpoint is deliberately
unlimited instead, protected by a 32-byte single-use token whose bcrypt
work only runs after the token matches.
The e2e tests read the issued token directly from Postgres rather than
through a test-support endpoint. An endpoint returning a reset token for
an arbitrary address is account takeover for every customer if it is ever
reachable, and an environment gate is thin protection against that.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven reported items, of which the first four had two root causes.
The active tab was invisible in dark mode because colorPrimary was
hardcoded to #1a1a1a in both themes. The accent now inverts with the
theme, and colorTextLightSolid inverts with it, or a near-white accent
would get antd's default white label and disappear.
The Category tab, Tag tab, and item-form category selector ignored the
theme entirely. antd declares main: lib/index.js and module: es/index.js,
so importing from 'antd' resolves to the ES build while 'antd/lib/...'
loads the CommonJS one — two copies, two React contexts, and no
ConfigProvider for anything deep-imported. Switching those files to
antd/es/* keeps the deep-import convention and shares the instance. This
was introduced by my own use of the lib path; es is correct under Vite.
Two storefront components had the same latent bug.
"Colour" is now "Color".
The Customers tab shows how many items each customer is holding, as a
link opening the item list with a Release button. Release mirrors the
customer's own cart removal — drop the cart row, return the item to
available, guarded on 'reserved' so it can never resurrect a sold item —
and deliberately sends no email about an action the customer did not
take. The count is a subquery rather than another join, which would have
multiplied rows and inflated order_count and total_spent_cents.
The Inventory tab filters by category, tags, price, and status, reusing
the storefront's parser and query builder so the two cannot drift.
Reserved is one option in a Status filter rather than a standalone toggle.
Also fixes two defects the screenshots exposed: the reserved-count link
bubbled to the row handler and opened the customer drawer behind the
dialog, and .admin-category-node had no CSS at all, so the tree node name,
item count, and actions ran together as one string.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The storefront showed no inventory after deploying the categories/tags
release. No data was lost: the code queried categories/item_tags/
items.category_id against a database where the migration had not been
run, and that failure was invisible at every layer.
Three changes, each addressing one layer:
Migrations now run at container start, so deployed code cannot be ahead
of the schema and the easily-forgotten manual `docker exec migrate.js
up` step disappears. migrate.js waits for Postgres to accept
connections first, since the NAS brings the DB container up slower than
the app, and still exits non-zero so a bad migration stops the
container rather than serving a half-migrated schema.
Express 4 does not forward a rejected async handler, and no error
middleware was mounted, so a failing query never responded at all. Async
routes are now wrapped and an error middleware guarantees a 500. A hung
request is indistinguishable from an empty result in the UI, which is
how a schema mismatch came to read as "the store has no items".
The storefront now separates "request failed" from "no items" and offers
a retry. fetchItems/fetchFilterOptions throw on a non-OK response rather
than returning the parsed error body, which would have been set as the
item list and crashed the grid on .map.
Also restores the project-context update from 7fb5764, which was left
out of PR #24 and ended up dangling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a self-referencing categories tree, a tag registry with
deterministic colours, and item_tags, plus admin CRUD for both.
GET /api/items now accepts category, tags, min_price and max_price.
Category matching walks the subtree with a recursive CTE so selecting a
parent includes everything filed beneath it; tags match with AND via a
count check, since ANY() alone would return items carrying only one of
them. Malformed filter params return 400 rather than being ignored, so a
broken link doesn't quietly list the whole catalogue.
GET /api/filters serves the drawer its tree, tags, and price bounds in
one request.
Item image/tag aggregation moves from LEFT JOIN + GROUP BY to scalar
subqueries. Joining two one-to-many relations multiplies their rows, so
an item with 2 images and 3 tags would have repeated every image three
times once tags were added.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
routes/paypal.ts and routes/demo.ts were the pre-cart single-item
checkout flow. Nothing has imported them since the cart flow landed:
app.ts mounts only cartCheckout, the frontend calls /api/checkout/cart/*,
and no test touches them. They duplicated PAYPAL_BASE, getAccessToken,
and a second handler for the /webhooks/paypal mount.
Also extract openCheckout() from /paypal/create and /demo/purchase in
cartCheckout.ts, which repeated the same address-ownership check, cart
lock, and checkouts/checkout_items inserts. It returns a discriminated
union so callers keep control of the transaction and the response. Add
CartItem/LockedCart interfaces, dropping the (it: any) casts.
Note: paypal.ts was the only writer of items.reserved_until and
items.paypal_order_id. Those columns are now write-dead; the schema is
left alone for a separate migration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
node-cron, node-pg-migrate, and @types/node-cron are declared in
package.json but were missing from the lockfile, so npm ci fails and a
plain npm install silently rewrites the lock. Regenerated to match.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- S5693: MAX_IMAGE_BYTES was 8 * 1024 * 1024 (8,388,608), just over the
8,000,000-byte ceiling the rule treats as safe, so the hotspot on the
multer storage config never cleared. Use 8_000_000.
- S5689: Express advertises its stack in X-Powered-By by default, which
tells an attacker what to aim exploits at. Disable the header.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SonarQube flagged three hotspots in the admin router: multer was
configured with no content length limits, and stored filenames were
derived from Date.now() plus Math.random().
- Cap the multipart body on every dimension: 6 files, 8 MiB per image,
8 fields, 64 KiB per field. Without limits a single request could
fill the uploads volume.
- Generate stored filenames with crypto.randomUUID() so paths are not
predictable. Image ordering is unaffected; sort_order already drives it.
- Wrap the upload middleware to translate MulterError into 413/400 JSON.
The app mounts no error handler, so a limit rejection would otherwise
surface as an HTML 500.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>