[object Object]

← back to Fromental Internal

Fromental internal viewer: Shopify products.json scraper + local Ollama enricher + basic-auth catalog viewer (:10071)

886861faa272aae5cb55ade9fb621d5c50ec3e72 · 2026-07-09 09:10:33 -0700 · Steve Abrams

Files touched

Diff

commit 886861faa272aae5cb55ade9fb621d5c50ec3e72
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 9 09:10:33 2026 -0700

    Fromental internal viewer: Shopify products.json scraper + local Ollama enricher + basic-auth catalog viewer (:10071)
---
 .gitignore                  |   10 +
 package-lock.json           | 1184 +++++++++++++++++++++++++++++++++++++++++++
 package.json                |   17 +
 public/index.html           |  285 +++++++++++
 scripts/enrich-fromental.js |  128 +++++
 scripts/scrape-fromental.js |  155 ++++++
 server.js                   |  134 +++++
 7 files changed, 1913 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1457bc2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+data/*.log
+data/enrich.log
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..ea5d347
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1184 @@
+{
+  "name": "fromental-internal",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "fromental-internal",
+      "version": "1.0.0",
+      "dependencies": {
+        "compression": "^1.8.1",
+        "express": "^5.2.1",
+        "pg": "^8.11.5"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+      "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "^3.0.0",
+        "negotiator": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/accepts/node_modules/negotiator": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+      "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/body-parser": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+      "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "^3.1.2",
+        "content-type": "^2.0.0",
+        "debug": "^4.4.3",
+        "http-errors": "^2.0.1",
+        "iconv-lite": "^0.7.2",
+        "on-finished": "^2.4.1",
+        "qs": "^6.15.2",
+        "raw-body": "^3.0.2",
+        "type-is": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/body-parser/node_modules/content-type": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+      "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/body-parser/node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/body-parser/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/compressible": {
+      "version": "2.0.18",
+      "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
+      "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": ">= 1.43.0 < 2"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/compression": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
+      "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "3.1.2",
+        "compressible": "~2.0.18",
+        "debug": "2.6.9",
+        "negotiator": "~0.6.4",
+        "on-headers": "~1.1.0",
+        "safe-buffer": "5.2.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+      "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+      "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.6.0"
+      }
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+      "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "^2.0.0",
+        "body-parser": "^2.2.1",
+        "content-disposition": "^1.0.0",
+        "content-type": "^1.0.5",
+        "cookie": "^0.7.1",
+        "cookie-signature": "^1.2.1",
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "finalhandler": "^2.1.0",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.0",
+        "merge-descriptors": "^2.0.0",
+        "mime-types": "^3.0.0",
+        "on-finished": "^2.4.1",
+        "once": "^1.4.0",
+        "parseurl": "^1.3.3",
+        "proxy-addr": "^2.0.7",
+        "qs": "^6.14.0",
+        "range-parser": "^1.2.1",
+        "router": "^2.2.0",
+        "send": "^1.1.0",
+        "serve-static": "^2.2.0",
+        "statuses": "^2.0.1",
+        "type-is": "^2.0.1",
+        "vary": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/express/node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/express/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/finalhandler": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+      "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "on-finished": "^2.4.1",
+        "parseurl": "^1.3.3",
+        "statuses": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/finalhandler/node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/finalhandler/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+      "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.7.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+      "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/is-promise": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+      "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+      "license": "MIT"
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+      "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+      "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.54.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+      "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "^1.54.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.4",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
+      "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/on-headers": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+      "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "8.4.2",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+      "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/pg": {
+      "version": "8.22.0",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
+      "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.14.0",
+        "pg-pool": "^3.14.0",
+        "pg-protocol": "^1.15.0",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.4.0"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+      "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.14.0",
+      "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+      "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+      "license": "MIT"
+    },
+    "node_modules/pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pg-pool": {
+      "version": "3.14.0",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+      "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
+      "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
+      "license": "MIT"
+    },
+    "node_modules/pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.1.0"
+      }
+    },
+    "node_modules/postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postgres-bytea": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+      "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.15.3",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "es-define-property": "^1.0.1",
+        "side-channel": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+      "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+      "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.7.0",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/router": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+      "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "is-promise": "^4.0.0",
+        "parseurl": "^1.3.3",
+        "path-to-regexp": "^8.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
+    "node_modules/router/node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/router/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+      "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.3",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.1",
+        "mime-types": "^3.0.2",
+        "ms": "^2.1.3",
+        "on-finished": "^2.4.1",
+        "range-parser": "^1.2.1",
+        "statuses": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/send/node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/serve-static": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+      "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "parseurl": "^1.3.3",
+        "send": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+      "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4",
+        "side-channel-list": "^1.0.1",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+      "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+      "license": "MIT",
+      "dependencies": {
+        "content-type": "^2.0.0",
+        "media-typer": "^1.1.0",
+        "mime-types": "^3.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/type-is/node_modules/content-type": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+      "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "license": "ISC"
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..6998a72
--- /dev/null
+++ b/package.json
@@ -0,0 +1,17 @@
+{
+  "name": "fromental-internal",
+  "version": "1.0.0",
+  "private": true,
+  "description": "Internal (basic-auth) catalog viewer + scraper for the Fromental line over dw_unified.fromental_catalog",
+  "main": "server.js",
+  "scripts": {
+    "start": "node server.js",
+    "scrape": "node scripts/scrape-fromental.js",
+    "enrich": "node scripts/enrich-fromental.js"
+  },
+  "dependencies": {
+    "compression": "^1.8.1",
+    "express": "^5.2.1",
+    "pg": "^8.11.5"
+  }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..527bb38
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,285 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>Fromental — Designer Wallcoverings (internal)</title>
+<style>
+  :root{
+    --bg:#faf9f7; --ink:#1a1a1a; --mut:#7a756e; --line:#e2ded7; --acc:#0f7d74;
+    --card:#fff; --cols:6; --fs:1;
+  }
+  *{box-sizing:border-box}
+  body{margin:0;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background:var(--bg);color:var(--ink);font-size:15px;line-height:1.4}
+  a{color:inherit}
+  header.top{position:sticky;top:0;z-index:20;background:rgba(250,249,247,.96);backdrop-filter:blur(6px);border-bottom:1px solid var(--line);padding:12px 18px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
+  .brand{font-weight:700;letter-spacing:.02em}
+  .brand .sub{color:var(--mut);font-weight:400;font-size:12px;margin-left:8px}
+  .count{color:var(--mut);font-size:12px}
+  .ctrls{margin-left:auto;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
+  .ctrls label{font-size:12px;color:var(--mut);display:flex;align-items:center;gap:6px}
+  select,input[type=search]{font:inherit;font-size:13px;padding:5px 8px;border:1px solid var(--line);border-radius:3px;background:#fff}
+  input[type=range]{width:120px;accent-color:var(--acc)}
+  .refresh{font-size:11px;color:var(--mut);border:1px solid var(--line);border-radius:20px;padding:3px 10px}
+
+  .wrap{display:grid;grid-template-columns:260px 1fr;gap:0;align-items:start}
+  aside{position:sticky;top:57px;max-height:calc(100vh - 57px);overflow:auto;border-right:1px solid var(--line);padding:14px;background:#fff}
+  aside .search{width:100%;margin-bottom:10px}
+  .clearbtn{width:100%;text-align:left;font-size:12px;color:var(--acc);background:none;border:none;cursor:pointer;padding:6px 2px;margin-bottom:6px}
+  details.facet{border-bottom:1px solid var(--line)}
+  details.facet>summary{cursor:pointer;list-style:none;padding:8px 2px;font-size:12px;font-weight:600;letter-spacing:.03em;text-transform:uppercase;color:#4a453f;display:flex;justify-content:space-between;align-items:center}
+  details.facet>summary::-webkit-details-marker{display:none}
+  details.facet>summary .badge{background:var(--acc);color:#fff;border-radius:10px;font-size:10px;padding:1px 7px;font-weight:600;text-transform:none;letter-spacing:0}
+  details.facet[data-active="1"]>summary{color:var(--acc)}
+  .facet .rows{padding:2px 0 8px}
+  .facet .row{display:flex;justify-content:space-between;gap:8px;font-size:12.5px;padding:3px 6px;cursor:pointer;border-radius:3px;color:#3a352f}
+  .facet .row:hover{background:#f2efe9}
+  .facet .row.sel{background:var(--acc);color:#fff}
+  .facet .row .n{color:var(--mut);font-variant-numeric:tabular-nums}
+  .facet .row.sel .n{color:#dff3f0}
+  .cardfields{padding:8px 2px 2px}
+  .cardfields .cf{display:flex;align-items:center;gap:7px;font-size:12.5px;padding:3px 4px;color:#3a352f}
+
+  main{padding:14px}
+  .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:8px}
+  .card{border:1px solid var(--line);border-radius:2px;background:var(--card);overflow:hidden;cursor:pointer;position:relative;transition:border-color .12s}
+  .card:hover{border-color:var(--acc)}
+  .card .imgwrap{aspect-ratio:1/1;background:#efece6;overflow:hidden}
+  .card img{width:100%;height:100%;object-fit:cover;display:block}
+  .card .b{padding:calc(7px*var(--fs)) calc(8px*var(--fs))}
+  .card .b .ttl{font-size:calc(12.5px*var(--fs));font-weight:600;line-height:1.25;margin-bottom:calc(2px*var(--fs))}
+  .card .b .col{font-size:calc(11px*var(--fs));color:var(--mut)}
+  .card .chips{display:flex;flex-wrap:wrap;gap:4px;margin-top:calc(5px*var(--fs))}
+  .chip{font-size:calc(9.5px*var(--fs));border:1px solid var(--line);border-radius:2px;padding:1px 5px;color:#5a554e;background:#fbfaf8}
+  .chip.q{border-color:var(--acc);color:var(--acc)}
+  .chip.p{border-color:#b58a2e;color:#8a6820}
+  .card .when{display:flex;align-items:center;gap:4px;font-size:calc(9.5px*var(--fs));color:var(--mut);margin-top:calc(5px*var(--fs))}
+  .dot{width:9px;height:9px;border-radius:50%;border:1px solid rgba(0,0,0,.15);flex:0 0 auto}
+  /* image-only mode */
+  body.imgonly .grid .card .b{display:none}
+
+  /* PDP drawer */
+  .drawer{position:fixed;inset:0;background:rgba(20,18,16,.5);display:none;z-index:40;align-items:stretch;justify-content:flex-end}
+  .drawer.open{display:flex}
+  .drawer .panel{width:min(560px,94vw);background:#fff;height:100%;overflow:auto;box-shadow:-8px 0 30px rgba(0,0,0,.2)}
+  .drawer .panel .hero{width:100%;aspect-ratio:1/1;object-fit:cover;background:#efece6;display:block}
+  .drawer .pad{padding:18px}
+  .drawer h2{margin:0 0 2px;font-size:20px}
+  .drawer .sub{color:var(--mut);margin-bottom:12px}
+  .drawer .row2{display:flex;justify-content:space-between;border-bottom:1px solid var(--line);padding:7px 0;font-size:13px}
+  .drawer .row2 .k{color:var(--mut)}
+  .drawer .desc{margin:12px 0;line-height:1.55;font-size:13.5px}
+  .drawer .cta{display:flex;gap:8px;margin:14px 0 4px}
+  .drawer .cta button{flex:1;font:inherit;font-size:13px;padding:10px;border:1px solid var(--acc);background:var(--acc);color:#fff;border-radius:3px;cursor:pointer}
+  .drawer .cta button.alt{background:#fff;color:var(--acc)}
+  .drawer .gal{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-top:10px}
+  .drawer .gal img{width:100%;aspect-ratio:1/1;object-fit:cover;border:1px solid var(--line);border-radius:2px}
+  .close{position:absolute;top:10px;right:14px;font-size:22px;color:#fff;cursor:pointer;text-shadow:0 1px 3px rgba(0,0,0,.6)}
+</style>
+</head>
+<body>
+<header class="top">
+  <div class="brand">Fromental <span class="sub">Designer Wallcoverings · internal line viewer</span></div>
+  <span class="count" id="count">…</span>
+  <div class="ctrls">
+    <label>Sort
+      <select id="sort">
+        <option value="newest">Newest</option>
+        <option value="color">Color</option>
+        <option value="style">Style</option>
+        <option value="type">Type</option>
+        <option value="sku">SKU A→Z</option>
+        <option value="title">Title A→Z</option>
+        <option value="light">Light→Dark</option>
+        <option value="dark">Dark→Light</option>
+        <option value="price_asc">Price ↑</option>
+        <option value="price_desc">Price ↓</option>
+      </select>
+    </label>
+    <label>Density <input type="range" id="density" min="3" max="20" value="6" /></label>
+    <span class="refresh" id="refresh">—</span>
+  </div>
+</header>
+
+<div class="wrap">
+  <aside>
+    <input class="search" type="search" id="search" placeholder="Search pattern, color, SKU…" list="patterns" />
+    <datalist id="patterns"></datalist>
+    <button class="clearbtn" id="clear">✕ Clear filters</button>
+    <div id="facets"></div>
+    <details class="facet" open>
+      <summary>Card fields</summary>
+      <div class="cardfields" id="cardfields">
+        <label class="cf"><input type="checkbox" data-f="ttl" checked> Title</label>
+        <label class="cf"><input type="checkbox" data-f="col" checked> Color</label>
+        <label class="cf"><input type="checkbox" data-f="chips" checked> Chips (type / price)</label>
+        <label class="cf"><input type="checkbox" data-f="when" checked> Created date</label>
+      </div>
+    </details>
+  </aside>
+
+  <main>
+    <div class="grid" id="grid"></div>
+  </main>
+</div>
+
+<div class="drawer" id="drawer"><div class="panel" id="panel"></div></div>
+
+<script>
+const ORIGIN = location.origin;
+const state = { all: [], facets: {}, filters: {}, q: '', sort: 'newest' };
+const FACET_LABELS = { type:'Type', collection:'Collection', color:'Color', material:'Material', width:'Width', style:'Style', tag:'Tag', pricing:'Pricing' };
+
+function lum(hex){ if(!hex||hex[0]!=='#'||hex.length!==7) return null; const r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16); return .2126*r+.7152*g+.0722*b; }
+function hue(hex){ if(!hex||hex[0]!=='#'||hex.length!==7) return 999; let r=parseInt(hex.slice(1,3),16)/255,g=parseInt(hex.slice(3,5),16)/255,b=parseInt(hex.slice(5,7),16)/255; const mx=Math.max(r,g,b),mn=Math.min(r,g,b),d=mx-mn; if(d===0)return 999; let h; if(mx===r)h=((g-b)/d)%6; else if(mx===g)h=(b-r)/d+2; else h=(r-g)/d+4; h*=60; if(h<0)h+=360; return h; }
+function fmtDate(d){ if(!d) return ''; try{ return new Date(d).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }catch{ return ''; } }
+
+function matches(p){
+  if(state.q){ const q=state.q.toLowerCase(); const hay=[p.pattern,p.color,p.sku,p.type,p.collection].filter(Boolean).join(' ').toLowerCase(); if(!hay.includes(q)) return false; }
+  for(const [field,val] of Object.entries(state.filters)){
+    if(val==null) continue;
+    if(field==='pricing'){ const isQuote=p.quote; if((val==='Quote-only')!==isQuote) return false; continue; }
+    if(field==='style'){ if(!(p.styles||[]).includes(val)) return false; continue; }
+    if(field==='tag'){ if(!(p.aiTags||[]).includes(val)) return false; continue; }
+    const map={type:'type',collection:'collection',color:'color',material:'material',width:'width'};
+    if(p[map[field]]!==val) return false;
+  }
+  return true;
+}
+function sortRows(rows){
+  const s=state.sort;
+  const by={
+    newest:(a,b)=> new Date(b.createdAt||0)-new Date(a.createdAt||0),
+    color:(a,b)=> (hue(a.hex)-hue(b.hex))||0,
+    style:(a,b)=> ((a.styles&&a.styles[0])||'zz').localeCompare((b.styles&&b.styles[0])||'zz'),
+    type:(a,b)=> (a.type||'').localeCompare(b.type||''),
+    sku:(a,b)=> (a.sku||'').localeCompare(b.sku||''),
+    title:(a,b)=> (a.pattern||'').localeCompare(b.pattern||''),
+    light:(a,b)=> (lum(b.hex)??-1)-(lum(a.hex)??-1),
+    dark:(a,b)=> (lum(a.hex)??999)-(lum(b.hex)??999),
+    price_asc:(a,b)=> (a.price??1e9)-(b.price??1e9),
+    price_desc:(a,b)=> (b.price??-1)-(a.price??-1),
+  };
+  return rows.slice().sort(by[s]||by.newest);
+}
+
+function render(){
+  const rows=sortRows(state.all.filter(matches));
+  document.getElementById('count').textContent=`${rows.length} of ${state.all.length}`;
+  const g=document.getElementById('grid');
+  g.innerHTML=rows.map(p=>{
+    const chips=[];
+    if(p.type) chips.push(`<span class="chip">${esc(p.type)}</span>`);
+    chips.push(p.quote?`<span class="chip q">Quote</span>`:(p.price!=null?`<span class="chip p">$${p.price.toLocaleString()}</span>`:''));
+    const dot=p.hex?`<span class="dot" style="background:${p.hex}"></span>`:'';
+    return `<div class="card" data-sku="${esc(p.sku)}">
+      <div class="imgwrap">${p.image?`<img loading="lazy" src="${esc(p.image)}" alt="${esc(p.pattern||'')}">`:''}</div>
+      <div class="b">
+        <div class="ttl f-ttl">${esc(p.pattern||p.sku)}</div>
+        ${p.color?`<div class="col f-col">${esc(p.color)}</div>`:''}
+        <div class="chips f-chips">${dot}${chips.join('')}</div>
+        ${p.createdAt?`<div class="when f-when" title="${esc(p.createdAt)}">🕓 ${fmtDate(p.createdAt)}</div>`:''}
+      </div>
+    </div>`;
+  }).join('');
+  applyCardFields();
+}
+function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
+
+// facets
+function renderFacets(){
+  const host=document.getElementById('facets');
+  host.innerHTML=Object.entries(state.facets).map(([field,rows])=>{
+    if(!rows||!rows.length) return '';
+    const active=state.filters[field];
+    const rowsHtml=rows.map(([v,n])=>`<div class="row ${active===v?'sel':''}" data-field="${field}" data-val="${esc(v)}"><span>${esc(v)}</span><span class="n">${n}</span></div>`).join('');
+    return `<details class="facet" data-active="${active?1:0}">
+      <summary>${FACET_LABELS[field]||field} ${active?`<span class="badge">${esc(active)}</span>`:''}</summary>
+      <div class="rows">${rowsHtml}</div>
+    </details>`;
+  }).join('');
+}
+
+// events
+document.addEventListener('click',(e)=>{
+  const row=e.target.closest('.facet .row');
+  if(row){ const f=row.dataset.field,v=row.dataset.val; state.filters[f]=(state.filters[f]===v)?null:v; renderFacets(); render(); return; }
+  const card=e.target.closest('.card'); if(card){ openDrawer(card.dataset.sku); }
+});
+document.getElementById('clear').addEventListener('click',()=>{ state.filters={}; state.q=''; document.getElementById('search').value=''; renderFacets(); render(); });
+document.getElementById('search').addEventListener('input',(e)=>{ state.q=e.target.value.trim(); render(); });
+document.getElementById('sort').addEventListener('change',(e)=>{ state.sort=e.target.value; localStorage.setItem('fr_sort',state.sort); render(); });
+document.getElementById('density').addEventListener('input',(e)=>setDensity(+e.target.value));
+function setDensity(cols){
+  document.documentElement.style.setProperty('--cols',cols);
+  const fs=Math.max(.5,Math.min(1.2, cols<=6 ? 1+(6-cols)*.06 : 1-(cols-6)*.042));
+  document.documentElement.style.setProperty('--fs',fs);
+  localStorage.setItem('fr_density',cols); document.getElementById('density').value=cols;
+}
+document.querySelectorAll('#cardfields input').forEach(cb=>cb.addEventListener('change',()=>{ saveCardFields(); applyCardFields(); }));
+function saveCardFields(){ const o={}; document.querySelectorAll('#cardfields input').forEach(cb=>o[cb.dataset.f]=cb.checked); localStorage.setItem('fr_cf',JSON.stringify(o)); }
+function applyCardFields(){
+  const o={}; document.querySelectorAll('#cardfields input').forEach(cb=>o[cb.dataset.f]=cb.checked);
+  document.querySelectorAll('.f-ttl').forEach(el=>el.style.display=o.ttl?'':'none');
+  document.querySelectorAll('.f-col').forEach(el=>el.style.display=o.col?'':'none');
+  document.querySelectorAll('.f-chips').forEach(el=>el.style.display=o.chips?'':'none');
+  document.querySelectorAll('.f-when').forEach(el=>el.style.display=o.when?'':'none');
+  const allOff=!o.ttl&&!o.col&&!o.chips&&!o.when;
+  document.body.classList.toggle('imgonly',allOff);
+}
+
+// drawer / PDP
+async function openDrawer(sku){
+  const d=document.getElementById('drawer'), panel=document.getElementById('panel');
+  d.classList.add('open'); panel.innerHTML='<div class="pad">Loading…</div>';
+  const p=await fetch(ORIGIN+'/api/product/'+encodeURIComponent(sku)).then(r=>r.json()).catch(()=>null);
+  if(!p){ panel.innerHTML='<div class="pad">Not found.</div>'; return; }
+  const gal=(p.gallery||[]).slice(0,8).map(g=>`<img src="${esc(g)}">`).join('');
+  const rowsSpec=[
+    ['SKU',p.sku],['Color',p.color],['Type',p.type],['Collection',p.collection],
+    ['Material',p.material],['Width',p.width],['Vendor',p.vendor],
+    ['Price', p.quote?'Quote-only (made to order)':(p.price!=null?'$'+p.price.toLocaleString():'—')],
+    ['Styles',(p.styles||[]).join(', ')],
+  ].filter(r=>r[1]).map(r=>`<div class="row2"><span class="k">${r[0]}</span><span>${esc(r[1])}</span></div>`).join('');
+  panel.innerHTML=`
+    <div style="position:relative"><span class="close" onclick="document.getElementById('drawer').classList.remove('open')">×</span>
+      ${p.image?`<img class="hero" src="${esc(p.image)}">`:''}</div>
+    <div class="pad">
+      <h2>${esc(p.pattern||p.sku)}</h2>
+      <div class="sub">${esc(p.color||'')} · Fromental</div>
+      <div class="cta">
+        <button>Request Quote</button>
+        <button class="alt">Order Memo Sample</button>
+      </div>
+      ${rowsSpec}
+      ${p.description?`<div class="desc">${esc(p.description)}</div>`:''}
+      ${gal?`<div class="gal">${gal}</div>`:''}
+    </div>`;
+}
+document.getElementById('drawer').addEventListener('click',(e)=>{ if(e.target.id==='drawer') e.target.classList.remove('open'); });
+
+// meta refresh pill
+async function tickMeta(){
+  try{ const m=await fetch(ORIGIN+'/api/meta').then(r=>r.json());
+    document.getElementById('refresh').textContent=`↻ ${m.count} · ${m.enriched} enriched`;
+  }catch{}
+}
+
+async function boot(){
+  // restore prefs
+  setDensity(+(localStorage.getItem('fr_density')||6));
+  state.sort=localStorage.getItem('fr_sort')||'newest'; document.getElementById('sort').value=state.sort;
+  const cf=localStorage.getItem('fr_cf'); if(cf){ try{ const o=JSON.parse(cf); document.querySelectorAll('#cardfields input').forEach(cb=>{ if(o[cb.dataset.f]===false) cb.checked=false; }); }catch{} }
+
+  const [prod,fac]=await Promise.all([
+    fetch(ORIGIN+'/api/products').then(r=>r.json()),
+    fetch(ORIGIN+'/api/facets').then(r=>r.json()),
+  ]);
+  state.all=prod.products; state.facets=fac;
+  document.getElementById('patterns').innerHTML=[...new Set(state.all.map(p=>p.pattern).filter(Boolean))].slice(0,400).map(v=>`<option value="${esc(v)}">`).join('');
+  renderFacets(); render(); tickMeta(); setInterval(tickMeta,60000);
+}
+boot();
+</script>
+</body>
+</html>
diff --git a/scripts/enrich-fromental.js b/scripts/enrich-fromental.js
new file mode 100644
index 0000000..0454349
--- /dev/null
+++ b/scripts/enrich-fromental.js
@@ -0,0 +1,128 @@
+#!/usr/bin/env node
+/**
+ * enrich-fromental.js — LOCAL ($0) Ollama enrichment of fromental_catalog.
+ *
+ * Steve HARD RULE: all design/image enrichment runs on LOCAL models, never paid
+ * Gemini/Claude-vision/Replicate. This uses Mac1 Ollama:
+ *   - qwen2.5vl:7b  -> vision: colors (hex+name), color_hex, styles, tags,
+ *                      and a clean description when body_html is thin.
+ *
+ * Resumable: skips rows where ai_accepted_at IS NOT NULL. Batches politely.
+ *   node scripts/enrich-fromental.js [limit]
+ *   LIMIT env or argv[2] caps rows this run (default 100). Pass "all" for全部.
+ *
+ * Cost: $0 (local Ollama, unmetered).
+ */
+const { Client } = require('pg');
+
+const DB = process.env.DATABASE_URL || 'postgresql://localhost/dw_unified';
+const OLLAMA = process.env.OLLAMA_URL || 'http://192.168.1.133:11434';
+const VISION_MODEL = process.env.VISION_MODEL || 'qwen2.5vl:7b';
+const argLimit = process.argv[2] || process.env.LIMIT || '100';
+const LIMIT = argLimit === 'all' ? 1000000 : parseInt(argLimit, 10);
+
+async function fetchImageB64(url) {
+  // Fromental images can be huge; request a Shopify-resized variant to keep it small.
+  let u = url;
+  if (/cdn\.shopify\.com/.test(u) && !/[?&]width=/.test(u)) {
+    u += (u.includes('?') ? '&' : '?') + 'width=600';
+  }
+  const res = await fetch(u, { headers: { 'User-Agent': 'DW-enrich/1.0' } });
+  if (!res.ok) throw new Error(`img HTTP ${res.status}`);
+  const buf = Buffer.from(await res.arrayBuffer());
+  return buf.toString('base64');
+}
+
+async function ollamaVision(prompt, imgB64) {
+  const res = await fetch(`${OLLAMA}/api/generate`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      model: VISION_MODEL,
+      prompt,
+      images: [imgB64],
+      stream: false,
+      format: 'json',
+      options: { temperature: 0.2 },
+    }),
+  });
+  if (!res.ok) throw new Error(`ollama HTTP ${res.status}`);
+  const j = await res.json();
+  return j.response;
+}
+
+function safeParse(s) {
+  if (!s) return null;
+  try { return JSON.parse(s); } catch {}
+  const m = s.match(/\{[\s\S]*\}/);
+  if (m) { try { return JSON.parse(m[0]); } catch {} }
+  return null;
+}
+
+const PROMPT = (row) => `You are a luxury interior-design cataloguer describing a Fromental hand-painted / embroidered wallcovering or artwork image.
+Pattern: "${row.pattern_name || ''}"  Colorway: "${row.color_name || ''}"  Type: "${row.product_type || ''}".
+Look at the image and return STRICT JSON with these keys only:
+{
+ "colors": [{"name":"warm ivory","hex":"#efe7d8"}, ...],   // 2-5 dominant colors, real hex you SEE
+ "dominant_hex": "#rrggbb",                                  // single most-dominant hex
+ "styles": ["Chinoiserie","Scenic", ...],                   // 1-4 interior/design styles
+ "tags": ["hand-painted","floral","birds", ...],            // 3-8 lowercase descriptive tags
+ "description": "..."                                        // 1-2 elegant sentences, editorial tone, NO price, NO brand claims beyond Fromental
+}
+Only output the JSON object.`;
+
+async function main() {
+  const client = new Client({ connectionString: DB });
+  await client.connect();
+
+  const { rows } = await client.query(
+    `SELECT id, mfr_sku, pattern_name, color_name, product_type, image_url, description
+       FROM fromental_catalog
+      WHERE ai_accepted_at IS NULL AND image_url IS NOT NULL
+      ORDER BY (description IS NULL) DESC, id
+      LIMIT $1`,
+    [LIMIT]
+  );
+  console.log(`Enriching ${rows.length} rows (model ${VISION_MODEL}, LOCAL $0)...`);
+
+  let ok = 0, fail = 0;
+  for (const row of rows) {
+    try {
+      const b64 = await fetchImageB64(row.image_url);
+      const raw = await ollamaVision(PROMPT(row), b64);
+      const j = safeParse(raw);
+      if (!j) { throw new Error('unparseable model output'); }
+
+      const colors = Array.isArray(j.colors) ? j.colors : [];
+      const domHex = typeof j.dominant_hex === 'string' && /^#[0-9a-fA-F]{6}$/.test(j.dominant_hex) ? j.dominant_hex : null;
+      const styles = Array.isArray(j.styles) ? j.styles : [];
+      const tags = Array.isArray(j.tags) ? j.tags : [];
+      const aiDesc = typeof j.description === 'string' ? j.description.trim() : null;
+      // only fill ai_description into description if the scraped one is thin
+      const useDesc = (!row.description || row.description.length < 40) ? aiDesc : null;
+
+      await client.query(
+        `UPDATE fromental_catalog SET
+           ai_colors=$2, color_hex=$3, dominant_color_hex=$3, ai_styles=$4, ai_tags=$5,
+           ai_description=$6,
+           description=COALESCE($7, description),
+           ai_accepted_at=NOW(), updated_at=NOW()
+         WHERE id=$1`,
+        [
+          row.id, JSON.stringify(colors), domHex, JSON.stringify(styles),
+          JSON.stringify(tags), aiDesc, useDesc,
+        ]
+      );
+      ok++;
+      if (ok % 10 === 0) process.stdout.write(`  ${ok} enriched...\n`);
+    } catch (e) {
+      fail++;
+      process.stderr.write(`  FAIL ${row.mfr_sku}: ${e.message}\n`);
+    }
+    await new Promise((r) => setTimeout(r, 150)); // polite pacing
+  }
+  console.log(`Done. enriched=${ok} failed=${fail}. Cost: $0 (local Ollama).`);
+  await client.end();
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });
diff --git a/scripts/scrape-fromental.js b/scripts/scrape-fromental.js
new file mode 100644
index 0000000..addbb04
--- /dev/null
+++ b/scripts/scrape-fromental.js
@@ -0,0 +1,155 @@
+#!/usr/bin/env node
+/**
+ * scrape-fromental.js — feed-first ($0) Fromental catalog scraper.
+ *
+ * Fromental (https://www.fromental.co.uk) is a CONFIRMED Shopify store, so we hit
+ * products.json?limit=250&page=N until a page returns 0 products. No auth, no
+ * proxy, no captcha, no paid API. Cost: $0 (local).
+ *
+ * Captures EVERYTHING per product:
+ *   title -> pattern_name, handle, product_type, body_html -> description,
+ *   image srcs -> image_url + gallery_images, variants (price/sku/options),
+ *   tags, vendor, options.
+ *
+ * Pricing: QUOTE-ONLY line. Most bespoke items are $0/blank (made-to-order);
+ * some Artwork / Shop / Accessory items carry real prices. We capture whatever
+ * price exists (max variant price), else leave NULL and mark quote_only=true.
+ *
+ * mfr_sku key resolution (idempotent upsert key):
+ *   1. first non-empty variant sku
+ *   2. else "handle:<handle>"
+ *   3. else "shopify:<id>"
+ *
+ * Usage: node scripts/scrape-fromental.js
+ */
+const { Client } = require('pg');
+
+const BASE = 'https://www.fromental.co.uk';
+const DB = process.env.DATABASE_URL || 'postgresql://localhost/dw_unified';
+
+async function fetchPage(page) {
+  const url = `${BASE}/products.json?limit=250&page=${page}`;
+  const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh) DW-catalog-sync' } });
+  if (!res.ok) throw new Error(`HTTP ${res.status} on ${url}`);
+  const j = await res.json();
+  return j.products || [];
+}
+
+function stripHtml(html) {
+  if (!html) return null;
+  return String(html)
+    .replace(/<[^>]+>/g, ' ')
+    .replace(/&nbsp;/g, ' ')
+    .replace(/&amp;/g, '&')
+    .replace(/&#39;|&rsquo;|&lsquo;/g, "'")
+    .replace(/&quot;|&ldquo;|&rdquo;/g, '"')
+    .replace(/&eacute;/g, 'e')
+    .replace(/\s{2,}/g, ' ')
+    .trim() || null;
+}
+
+// "Myeoh - Minhwa" -> pattern "Myeoh", color/variant "Minhwa"
+function splitTitle(title) {
+  if (!title) return { pattern: null, color: null };
+  const parts = title.split(/\s+[-–—]\s+/);
+  if (parts.length >= 2) return { pattern: parts[0].trim(), color: parts.slice(1).join(' - ').trim() };
+  return { pattern: title.trim(), color: null };
+}
+
+// Fromental SKU pattern e.g. AW-YH01-02-OS-A ; collection ~ the alpha prefix family
+function inferCollection(product, sku) {
+  // prefer a "collection" style tag if present
+  const tags = product.tags || [];
+  const t = (Array.isArray(tags) ? tags : String(tags).split(',')).map((s) => s.trim()).filter(Boolean);
+  const col = t.find((x) => /collection|series/i.test(x));
+  if (col) return col.replace(/collection[:\s-]*/i, '').trim();
+  return null;
+}
+
+function pickPrice(variants) {
+  const prices = (variants || [])
+    .map((v) => parseFloat(v.price))
+    .filter((n) => Number.isFinite(n) && n > 0);
+  if (!prices.length) return { price: null, quote: true };
+  return { price: Math.max(...prices), quote: false };
+}
+
+function resolveMfrSku(product) {
+  const v = (product.variants || []).find((x) => x.sku && String(x.sku).trim());
+  if (v) return String(v.sku).trim();
+  if (product.handle) return `handle:${product.handle}`;
+  return `shopify:${product.id}`;
+}
+
+async function main() {
+  const client = new Client({ connectionString: DB });
+  await client.connect();
+
+  let page = 1, all = [];
+  while (true) {
+    const products = await fetchPage(page);
+    if (!products.length) break;
+    all = all.concat(products);
+    process.stdout.write(`  page ${page}: ${products.length} (total ${all.length})\n`);
+    page++;
+    if (page > 100) break; // safety
+    await new Promise((r) => setTimeout(r, 300)); // polite
+  }
+  console.log(`Fetched ${all.length} products across ${page - 1} pages.`);
+
+  let up = 0;
+  for (const p of all) {
+    const mfr_sku = resolveMfrSku(p);
+    const { pattern, color } = splitTitle(p.title);
+    const images = (p.images || []).map((im) => im.src).filter(Boolean);
+    const { price, quote } = pickPrice(p.variants);
+    const cmp = (p.variants || [])
+      .map((v) => parseFloat(v.compare_at_price))
+      .filter((n) => Number.isFinite(n) && n > 0);
+    const compareAt = cmp.length ? Math.max(...cmp) : null;
+    const tags = Array.isArray(p.tags) ? p.tags : String(p.tags || '').split(',').map((s) => s.trim()).filter(Boolean);
+    const collection = inferCollection(p, mfr_sku);
+    const productUrl = `${BASE}/products/${p.handle}`;
+
+    await client.query(
+      `INSERT INTO fromental_catalog
+        (mfr_sku, shopify_id, handle, pattern_name, color_name, collection,
+         image_url, product_url, product_type, description, tags, vendor,
+         gallery_images, variants, options, price, compare_at_price, quote_only,
+         published_at, vendor_created_at, last_scraped, updated_at)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,NOW(),NOW())
+       ON CONFLICT (mfr_sku) DO UPDATE SET
+         shopify_id=EXCLUDED.shopify_id, handle=EXCLUDED.handle,
+         pattern_name=EXCLUDED.pattern_name, color_name=EXCLUDED.color_name,
+         collection=EXCLUDED.collection, image_url=EXCLUDED.image_url,
+         product_url=EXCLUDED.product_url, product_type=EXCLUDED.product_type,
+         description=EXCLUDED.description, tags=EXCLUDED.tags, vendor=EXCLUDED.vendor,
+         gallery_images=EXCLUDED.gallery_images, variants=EXCLUDED.variants,
+         options=EXCLUDED.options, price=EXCLUDED.price,
+         compare_at_price=EXCLUDED.compare_at_price, quote_only=EXCLUDED.quote_only,
+         published_at=EXCLUDED.published_at, vendor_created_at=EXCLUDED.vendor_created_at,
+         last_scraped=NOW(), updated_at=NOW()`,
+      [
+        mfr_sku, p.id, p.handle, pattern, color, collection,
+        images[0] || null, productUrl, p.product_type || null,
+        stripHtml(p.body_html), tags, p.vendor || 'Fromental',
+        images, JSON.stringify(p.variants || []), JSON.stringify(p.options || []),
+        price, compareAt, quote,
+        p.published_at ? new Date(p.published_at) : null,
+        p.created_at ? new Date(p.created_at) : null,
+      ]
+    );
+    up++;
+  }
+  console.log(`Upserted ${up} rows into fromental_catalog.`);
+  const { rows } = await client.query(
+    `SELECT count(*) total,
+            count(*) FILTER (WHERE price IS NOT NULL) priced,
+            count(*) FILTER (WHERE quote_only) quote
+       FROM fromental_catalog`
+  );
+  console.log('Table state:', rows[0]);
+  await client.end();
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..8d07da8
--- /dev/null
+++ b/server.js
@@ -0,0 +1,134 @@
+/* Fromental — INTERNAL line viewer (basic-auth, read-only over dw_unified.fromental_catalog).
+   Built to Steve's internal-line-viewer standing spec (astek-landing :9944 is the reference):
+   left panel with search + per-field collapsible tab tables (values+counts, click-to-filter),
+   card-field visibility toggles down to image-only, type scale tied to the density slider,
+   created-date chips, location.origin fetches, sort + density persisted in localStorage.
+
+   Reads fromental_catalog LIVE (refreshes an in-memory snapshot every REFRESH_INTERVAL_SEC).
+   Fromental is CUSTOMER-FACING (not private-label) but this viewer is an internal curation
+   surface — quote-only, sample-order only, no Shopify links. */
+const express = require('express');
+const path = require('path');
+const { Client } = require('pg');
+
+const PORT = process.env.PORT || 10071;
+const DB = process.env.DATABASE_URL || 'postgresql://localhost/dw_unified';
+const REFRESH_INTERVAL_SEC = Number(process.env.REFRESH_INTERVAL_SEC || 900);
+const [AUTH_USER, AUTH_PASS] = (process.env.BASIC_AUTH || 'admin:DW2024!').split(':');
+
+const app = express();
+app.use(require('compression')());
+
+let SNAP = { products: [], facets: {}, count: 0 };
+let LAST_REFRESH = new Date().toISOString();
+
+function titleCase(s) {
+  return String(s || '').replace(/\w\S*/g, (t) => t.charAt(0).toUpperCase() + t.slice(1).toLowerCase());
+}
+
+async function load() {
+  const client = new Client({ connectionString: DB });
+  await client.connect();
+  const { rows } = await client.query(`
+    SELECT id, mfr_sku, handle, pattern_name, color_name, collection, product_type,
+           material, composition, width, roll_length, image_url, gallery_images,
+           product_url, description, tags, vendor, price, compare_at_price, quote_only,
+           color_hex, dominant_color_hex, ai_colors, ai_styles, ai_tags, ai_description,
+           ai_accepted_at, crawled_at, vendor_created_at, updated_at
+      FROM fromental_catalog
+     ORDER BY (vendor_created_at IS NULL), vendor_created_at DESC NULLS LAST, id DESC`);
+  await client.end();
+
+  const products = rows.map((r) => ({
+    id: r.id,
+    sku: r.mfr_sku,
+    handle: r.handle,
+    pattern: r.pattern_name,
+    color: r.color_name,
+    collection: r.collection,
+    type: r.product_type,
+    material: r.material,
+    composition: r.composition,
+    width: r.width,
+    length: r.roll_length,
+    image: r.image_url,
+    gallery: r.gallery_images || [],
+    productUrl: r.product_url,
+    description: r.description || r.ai_description || null,
+    tags: r.tags || [],
+    vendor: (r.vendor || 'Fromental').replace(/\s*Design$/i, ''),
+    price: r.price != null ? Number(r.price) : null,
+    quote: !!r.quote_only && r.price == null,
+    hex: r.dominant_color_hex || r.color_hex || null,
+    styles: r.ai_styles || [],
+    aiTags: r.ai_tags || [],
+    enriched: !!r.ai_accepted_at,
+    createdAt: r.vendor_created_at || r.crawled_at,
+  }));
+
+  // Facets — one entry per filterable field: [value, count], sorted by count desc.
+  const facet = (keyFn) => {
+    const m = new Map();
+    for (const p of products) {
+      const vals = keyFn(p);
+      for (const v of (Array.isArray(vals) ? vals : [vals])) {
+        if (v == null || v === '') continue;
+        m.set(v, (m.get(v) || 0) + 1);
+      }
+    }
+    return [...m.entries()].sort((a, b) => b[1] - a[1]);
+  };
+  const facets = {
+    type: facet((p) => p.type),
+    collection: facet((p) => p.collection),
+    color: facet((p) => p.color),
+    material: facet((p) => p.material),
+    width: facet((p) => p.width),
+    style: facet((p) => p.styles),
+    tag: facet((p) => p.aiTags),
+    pricing: facet((p) => (p.quote ? 'Quote-only' : 'Priced')),
+  };
+
+  SNAP = { products, facets, count: products.length };
+  LAST_REFRESH = new Date().toISOString();
+  console.log(`[fromental] loaded ${products.length} products (${products.filter((p) => p.enriched).length} enriched)`);
+}
+
+// health probe BEFORE auth (deploy smoke + uptime monitors)
+app.get('/healthz', (_req, res) => res.json({ ok: true, products: SNAP.count, lastRefresh: LAST_REFRESH }));
+
+app.use((req, res, next) => {
+  const [scheme, b64] = (req.headers.authorization || '').split(' ');
+  if (scheme === 'Basic' && b64) {
+    const [u, p] = Buffer.from(b64, 'base64').toString('utf8').split(':');
+    if (u === AUTH_USER && p === AUTH_PASS) return next();
+  }
+  res.set('WWW-Authenticate', 'Basic realm="Fromental (Designer Wallcoverings internal)"');
+  return res.status(401).send('Authentication required.');
+});
+
+app.use(express.json({ limit: '2mb' }));
+
+app.get('/api/meta', (_req, res) => res.json({
+  lastRefresh: LAST_REFRESH, intervalSec: REFRESH_INTERVAL_SEC,
+  count: SNAP.count, enriched: SNAP.products.filter((p) => p.enriched).length,
+  now: new Date().toISOString(),
+}));
+// light list — drop heavy fields the grid never renders
+app.get('/api/products', (_req, res) => res.json({
+  count: SNAP.count,
+  products: SNAP.products.map(({ gallery, description, ...p }) => p),
+}));
+app.get('/api/facets', (_req, res) => res.json(SNAP.facets));
+app.get('/api/product/:sku', (req, res) => {
+  const p = SNAP.products.find((x) => x.sku === req.params.sku);
+  if (!p) return res.status(404).json({ error: 'not found' });
+  res.json(p);
+});
+
+app.use(express.static(path.join(__dirname, 'public')));
+
+load().then(() => {
+  setInterval(() => load().catch((e) => console.error('[fromental] reload failed:', e.message)), REFRESH_INTERVAL_SEC * 1000);
+  app.listen(PORT, () => console.log(`Fromental internal viewer → http://127.0.0.1:${PORT}  (basic-auth ${AUTH_USER})`));
+}).catch((e) => { console.error('initial load failed:', e); process.exit(1); });

(oldest)  ·  back to Fromental Internal  ·  Strip product count from unauthenticated /healthz a8b60f7 →