openvibers/openvibe.shoes Commits
OpenVibe.Shoes v1
Changes
nnew file mode 100644@@@ -0,0 +1,80 @@++# OpenVibe.Shoes — environment (production: /etc/openvibe/shoes.env). Names only; never commit values.++++NODE_ENV=production++PORT=5180++HOST=127.0.0.1++BASE_URL=https://open.shoes++TRUST_PROXY=2++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh: the runtime role through PgBouncer,++# the owner on a direct connection (migrations), and Valkey (shared limit counters).++# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.++DATABASE_URL=++DATABASE_DIRECT_URL=++VALKEY_URL=++VALKEY_PREFIX=ov:shoes:++# Per-address limits (server/app.js).++#SHOES_API_RATE_LIMIT_PER_MIN=240++#SHOES_RATE_LIMIT_PER_MIN=300++++# OpenVibe.Network: SSO (OAuth client `shoes`, PKCE S256) and its JWKS.++OV_NETWORK_URL=https://openvibe.network++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++# OV_NETWORK_ISSUER=https://openvibe.network++OV_OAUTH_CLIENT_ID=shoes++OV_OAUTH_CLIENT_SECRET=++# OV_OAUTH_REDIRECT_URI=https://open.shoes/auth/callback++# SHOES_AUDIENCE=open.shoes++# OV_SESSION_AUDIENCE=openvibe.network++# SHOES_PGLITE_DIR=./data/pglite++++# OpenVibe.Events: account export/deletion deliveries to POST /internal/events. The secret signs a delivery++# (comma-separated for rotation, 32+ characters each; unset makes the route answer 503).++SHOES_EVENTS_SECRET=++SHOES_EVENTS_URL=++#SHOES_EVENTS_ENDPOINT=http://127.0.0.1:5180/internal/events++#SHOES_EVENTS_INTERVAL_MS=5000++++# The sources the jobs read. OpenVibe.Deals' public API needs no key.++SHOES_DEALS_URL=https://openvibe.deals++# How many newest offer pages per run (25 offers a page; 20 pages = 500 offers).++#SHOES_OFFERS_PAGES=20++# How many product lookups per run (the public product record gives the brand).++#SHOES_PRODUCT_LOOKUPS=300++#SHOES_STALE_HOURS=48++#SHOES_UPSTREAM_TIMEOUT_MS=15000++# A polite gap between reads of the public source, in milliseconds (0 turns it off).++#SHOES_REQUEST_GAP_MS=250++++# The official release calendar (a brand's or a store's own page), and optional RSS feeds as a JSON array:++# SHOES_DROP_FEEDS=[{"url":"https://example.com/releases.rss","name":"Example releases","type":"rss"}]++SHOES_DROPS_URL=https://www.nike.com/launch++SHOES_DROPS_SOURCE_NAME=Nike SNKRS launch calendar++++# eBay's official Browse API: off unless both are set. Never commit the pair; production reads it from this file.++SHOES_EBAY_CLIENT_ID=++SHOES_EBAY_CLIENT_SECRET=++#SHOES_EBAY_MARKETPLACE=EBAY_US++#SHOES_EBAY_CATEGORY_ID=93427++#SHOES_EBAY_LIMIT=50++++# OpenVibe.Watch: a service token with watch.watch.manage, and the person in X-OV-Subject. Without a token a watch++# is saved on Shoes and marked "not connected" (a plain note, never an error).++SHOES_WATCH_URL=https://openvibe.watch++SHOES_WATCH_INTERNAL_URL=http://127.0.0.1:4730++SHOES_WATCH_TOKEN=++#SHOES_WATCH_EVERY_SEC=21600++++# OpenVibe.Today: the drops calendar's reminders (the person's own Network token is the Bearer).++SHOES_TODAY_URL=https://openvibe.today++SHOES_TODAY_INTERNAL_URL=http://127.0.0.1:5100++++# Job intervals (off under SHOES_DRILL=1).++#SHOES_OFFERS_INTERVAL_MS=1800000++#SHOES_DROPS_INTERVAL_MS=21600000++#SHOES_EBAY_INTERVAL_MS=3600000++++# A restore drill starts no jobs and creates no subscriptions.++#SHOES_DRILL=0++++# optional: defaults to true in production++# COOKIE_SECURE=truennew file mode 100644@@@ -0,0 +1,17 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++jobs:++ # install, node --check and npm test: the shared test job (Track Q).++ test:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176++ with:++ syntax-dirs: server test scripts++ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk++ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'++ # Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).++ security:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176nnew file mode 100644@@@ -0,0 +1,11 @@++node_modules/++data/++dist/++.env++.env.*++!.env.example++.npm-cache/++*.log++*.pem++*.key++!server/data/nnew file mode 100644@@@ -0,0 +1,31 @@++# Changelog++++What changed in OpenVibe.Shoes, newest first.++++## 0.1.0 — 2026-10-31++++The first version: every shoe, every market, the right price.++++- **Models and offers.** `shoe_models` and `shoe_offers`: one page per model (`/shoe/<brand>-<model>`), every++ store's offer with price, shipping, list price, condition, size and colourway when the source names them, the++ source's own check time and a stale flag. The all-in price is price + shipping when shipping is stated.++- **The offers job.** Every 30 minutes (off under `SHOES_DRILL`) it reads OpenVibe.Deals' public newest offers,++ keeps the footwear (a footwear word or a model only a shoe has), reads each product's public record for its++ brand, and writes the offers into Shoes' own catalogue. One snapshot per offer per day.++- **The shoe page.** Current prices by store, the price history drawn from Shoes' own snapshots, the size-price++ curve where sizes are known, colourways, and cited specs/fit/release/care — or "not known yet".++- **The drops job.** The official release calendar (Nike SNKRS by default) and optional RSS feeds, parsed by a++ pure parser that never invents a date; each release keeps its source page and read time.++- **Watches.** A price watch at a size (`POST /api/v1/me/watches`) and a pasted listing link++ (`POST /api/v1/me/track`) are created in OpenVibe.Watch with the person in `X-OV-Subject`; a pasted URL goes++ through `openvibe-shared/egress` first. Without a service token the watch is saved and marked "not connected".++- **Today.** A drop reminder goes on the person's own Today agenda with their Network token. Raffles are never++ automated.++- **The API.** Search with facets, one model with offers/history/curve/facts, the price a Watch polls, drops, the++ person's watches and a pasted link, the source status, and an OpenAPI 3.1 document.++- **The home.** `sc.room`: hero, live proof, the biggest price drops today, dropping this week, popular models,++ three scenes (a size-price curve, a drop reminder on Today, a pasted Marketplace link), explore, developers,++ questions, and the rest of OpenVibe.++- **Account data.** A person's watches are exported and deleted with their account; the catalogue is the++ network's public record.++- **No affiliate links, no Marketplace scraping, no invented specs.**nnew file mode 100644@@@ -0,0 +1,661 @@++ GNU AFFERO GENERAL PUBLIC LICENSE++ Version 3, 19 November 2007++++ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>++ Everyone is permitted to copy and distribute verbatim copies++ of this license document, but changing it is not allowed.++++ Preamble++++ The GNU Affero General Public License is a free, copyleft license for++software and other kinds of works, specifically designed to ensure++cooperation with the community in the case of network server software.++++ The licenses for most software and other practical works are designed++to take away your freedom to share and change the works. By contrast,++our General Public Licenses are intended to guarantee your freedom to++share and change all versions of a program--to make sure it remains free++software for all its users.++++ When we speak of free software, we are referring to freedom, not++price. Our General Public Licenses are designed to make sure that you++have the freedom to distribute copies of free software (and charge for++them if you wish), that you receive source code or can get it if you++want it, that you can change the software or use pieces of it in new++free programs, and that you know you can do these things.++++ Developers that use our General Public Licenses protect your rights++with two steps: (1) assert copyright on the software, and (2) offer++you this License which gives you legal permission to copy, distribute++and/or modify the software.++++ A secondary benefit of defending all users' freedom is that++improvements made in alternate versions of the program, if they++receive widespread use, become available for other developers to++incorporate. Many developers of free software are heartened and++encouraged by the resulting cooperation. However, in the case of++software used on network servers, this result may fail to come about.++The GNU General Public License permits making a modified version and++letting the public access it on a server without ever releasing its++source code to the public.++++ The GNU Affero General Public License is designed specifically to++ensure that, in such cases, the modified source code becomes available++to the community. It requires the operator of a network server to++provide the source code of the modified version running there to the++users of that server. Therefore, public use of a modified version, on++a publicly accessible server, gives the public access to the source++code of the modified version.++++ An older license, called the Affero General Public License and++published by Affero, was designed to accomplish similar goals. This is++a different license, not a version of the Affero GPL, but Affero has++released a new version of the Affero GPL which permits relicensing under++this license.++++ The precise terms and conditions for copying, distribution and++modification follow.++++ TERMS AND CONDITIONS++++ 0. Definitions.++++ "This License" refers to version 3 of the GNU Affero General Public License.++++ "Copyright" also means copyright-like laws that apply to other kinds of++works, such as semiconductor masks.++++ "The Program" refers to any copyrightable work licensed under this++License. Each licensee is addressed as "you". "Licensees" and++"recipients" may be individuals or organizations.++++ To "modify" a work means to copy from or adapt all or part of the work++in a fashion requiring copyright permission, other than the making of an++exact copy. The resulting work is called a "modified version" of the++earlier work or a work "based on" the earlier work.++++ A "covered work" means either the unmodified Program or a work based++on the Program.++++ To "propagate" a work means to do anything with it that, without++permission, would make you directly or secondarily liable for++infringement under applicable copyright law, except executing it on a++computer or modifying a private copy. Propagation includes copying,++distribution (with or without modification), making available to the++public, and in some countries other activities as well.++++ To "convey" a work means any kind of propagation that enables other++parties to make or receive copies. Mere interaction with a user through++a computer network, with no transfer of a copy, is not conveying.++++ An interactive user interface displays "Appropriate Legal Notices"++to the extent that it includes a convenient and prominently visible++feature that (1) displays an appropriate copyright notice, and (2)++tells the user that there is no warranty for the work (except to the++extent that warranties are provided), that licensees may convey the++work under this License, and how to view a copy of this License. If++the interface presents a list of user commands or options, such as a++menu, a prominent item in the list meets this criterion.++++ 1. Source Code.++++ The "source code" for a work means the preferred form of the work++for making modifications to it. "Object code" means any non-source++form of a work.++++ A "Standard Interface" means an interface that either is an official++standard defined by a recognized standards body, or, in the case of++interfaces specified for a particular programming language, one that++is widely used among developers working in that language.++++ The "System Libraries" of an executable work include anything, other++than the work as a whole, that (a) is included in the normal form of++packaging a Major Component, but which is not part of that Major++Component, and (b) serves only to enable use of the work with that++Major Component, or to implement a Standard Interface for which an++implementation is available to the public in source code form. A++"Major Component", in this context, means a major essential component++(kernel, window system, and so on) of the specific operating system++(if any) on which the executable work runs, or a compiler used to++produce the work, or an object code interpreter used to run it.++++ The "Corresponding Source" for a work in object code form means all++the source code needed to generate, install, and (for an executable++work) run the object code and to modify the work, including scripts to++control those activities. However, it does not include the work's++System Libraries, or general-purpose tools or generally available free++programs which are used unmodified in performing those activities but++which are not part of the work. For example, Corresponding Source++includes interface definition files associated with source files for++the work, and the source code for shared libraries and dynamically++linked subprograms that the work is specifically designed to require,++such as by intimate data communication or control flow between those++subprograms and other parts of the work.++++ The Corresponding Source need not include anything that users++can regenerate automatically from other parts of the Corresponding++Source.++++ The Corresponding Source for a work in source code form is that++same work.++++ 2. Basic Permissions.++++ All rights granted under this License are granted for the term of++copyright on the Program, and are irrevocable provided the stated++conditions are met. This License explicitly affirms your unlimited++permission to run the unmodified Program. The output from running a++covered work is covered by this License only if the output, given its++content, constitutes a covered work. This License acknowledges your++rights of fair use or other equivalent, as provided by copyright law.++++ You may make, run and propagate covered works that you do not++convey, without conditions so long as your license otherwise remains++in force. You may convey covered works to others for the sole purpose++of having them make modifications exclusively for you, or provide you++with facilities for running those works, provided that you comply with++the terms of this License in conveying all material for which you do++not control copyright. Those thus making or running the covered works++for you must do so exclusively on your behalf, under your direction++and control, on terms that prohibit them from making any copies of++your copyrighted material outside their relationship with you.++++ Conveying under any other circumstances is permitted solely under++the conditions stated below. Sublicensing is not allowed; section 10++makes it unnecessary.++++ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.++++ No covered work shall be deemed part of an effective technological++measure under any applicable law fulfilling obligations under article++11 of the WIPO copyright treaty adopted on 20 December 1996, or++similar laws prohibiting or restricting circumvention of such++measures.++++ When you convey a covered work, you waive any legal power to forbid++circumvention of technological measures to the extent such circumvention++is effected by exercising rights under this License with respect to++the covered work, and you disclaim any intention to limit operation or++modification of the work as a means of enforcing, against the work's++users, your or third parties' legal rights to forbid circumvention of++technological measures.++++ 4. Conveying Verbatim Copies.++++ You may convey verbatim copies of the Program's source code as you++receive it, in any medium, provided that you conspicuously and++appropriately publish on each copy an appropriate copyright notice;++keep intact all notices stating that this License and any++non-permissive terms added in accord with section 7 apply to the code;++keep intact all notices of the absence of any warranty; and give all++recipients a copy of this License along with the Program.++++ You may charge any price or no price for each copy that you convey,++and you may offer support or warranty protection for a fee.++++ 5. Conveying Modified Source Versions.++++ You may convey a work based on the Program, or the modifications to++produce it from the Program, in the form of source code under the++terms of section 4, provided that you also meet all of these conditions:++++ a) The work must carry prominent notices stating that you modified++ it, and giving a relevant date.++++ b) The work must carry prominent notices stating that it is++ released under this License and any conditions added under section++ 7. This requirement modifies the requirement in section 4 to++ "keep intact all notices".++++ c) You must license the entire work, as a whole, under this++ License to anyone who comes into possession of a copy. This++ License will therefore apply, along with any applicable section 7++ additional terms, to the whole of the work, and all its parts,++ regardless of how they are packaged. This License gives no++ permission to license the work in any other way, but it does not++ invalidate such permission if you have separately received it.++++ d) If the work has interactive user interfaces, each must display++ Appropriate Legal Notices; however, if the Program has interactive++ interfaces that do not display Appropriate Legal Notices, your++ work need not make them do so.++++ A compilation of a covered work with other separate and independent++works, which are not by their nature extensions of the covered work,++and which are not combined with it such as to form a larger program,++in or on a volume of a storage or distribution medium, is called an++"aggregate" if the compilation and its resulting copyright are not++used to limit the access or legal rights of the compilation's users++beyond what the individual works permit. Inclusion of a covered work++in an aggregate does not cause this License to apply to the other++parts of the aggregate.++++ 6. Conveying Non-Source Forms.++++ You may convey a covered work in object code form under the terms++of sections 4 and 5, provided that you also convey the++machine-readable Corresponding Source under the terms of this License,++in one of these ways:++++ a) Convey the object code in, or embodied in, a physical product++ (including a physical distribution medium), accompanied by the++ Corresponding Source fixed on a durable physical medium++ customarily used for software interchange.++++ b) Convey the object code in, or embodied in, a physical product++ (including a physical distribution medium), accompanied by a++ written offer, valid for at least three years and valid for as++ long as you offer spare parts or customer support for that product++ model, to give anyone who possesses the object code either (1) a++ copy of the Corresponding Source for all the software in the++ product that is covered by this License, on a durable physical++ medium customarily used for software interchange, for a price no++ more than your reasonable cost of physically performing this++ conveying of source, or (2) access to copy the++ Corresponding Source from a network server at no charge.++++ c) Convey individual copies of the object code with a copy of the++ written offer to provide the Corresponding Source. This++ alternative is allowed only occasionally and noncommercially, and++ only if you received the object code with such an offer, in accord++ with subsection 6b.++++ d) Convey the object code by offering access from a designated++ place (gratis or for a charge), and offer equivalent access to the++ Corresponding Source in the same way through the same place at no++ further charge. You need not require recipients to copy the++ Corresponding Source along with the object code. If the place to++ copy the object code is a network server, the Corresponding Source++ may be on a different server (operated by you or a third party)++ that supports equivalent copying facilities, provided you maintain++ clear directions next to the object code saying where to find the++ Corresponding Source. Regardless of what server hosts the++ Corresponding Source, you remain obligated to ensure that it is++ available for as long as needed to satisfy these requirements.++++ e) Convey the object code using peer-to-peer transmission, provided++ you inform other peers where the object code and Corresponding++ Source of the work are being offered to the general public at no++ charge under subsection 6d.++++ A separable portion of the object code, whose source code is excluded++from the Corresponding Source as a System Library, need not be++included in conveying the object code work.++++ A "User Product" is either (1) a "consumer product", which means any++tangible personal property which is normally used for personal, family,++or household purposes, or (2) anything designed or sold for incorporation++into a dwelling. In determining whether a product is a consumer product,++doubtful cases shall be resolved in favor of coverage. For a particular++product received by a particular user, "normally used" refers to a++typical or common use of that class of product, regardless of the status++of the particular user or of the way in which the particular user++actually uses, or expects or is expected to use, the product. A product++is a consumer product regardless of whether the product has substantial++commercial, industrial or non-consumer uses, unless such uses represent++the only significant mode of use of the product.++++ "Installation Information" for a User Product means any methods,++procedures, authorization keys, or other information required to install++and execute modified versions of a covered work in that User Product from++a modified version of its Corresponding Source. The information must++suffice to ensure that the continued functioning of the modified object++code is in no case prevented or interfered with solely because++modification has been made.++++ If you convey an object code work under this section in, or with, or++specifically for use in, a User Product, and the conveying occurs as++part of a transaction in which the right of possession and use of the++User Product is transferred to the recipient in perpetuity or for a++fixed term (regardless of how the transaction is characterized), the++Corresponding Source conveyed under this section must be accompanied++by the Installation Information. But this requirement does not apply++if neither you nor any third party retains the ability to install++modified object code on the User Product (for example, the work has++been installed in ROM).++++ The requirement to provide Installation Information does not include a++requirement to continue to provide support service, warranty, or updates++for a work that has been modified or installed by the recipient, or for++the User Product in which it has been modified or installed. Access to a++network may be denied when the modification itself materially and++adversely affects the operation of the network or violates the rules and++protocols for communication across the network.++++ Corresponding Source conveyed, and Installation Information provided,++in accord with this section must be in a format that is publicly++documented (and with an implementation available to the public in++source code form), and must require no special password or key for++unpacking, reading or copying.++++ 7. Additional Terms.++++ "Additional permissions" are terms that supplement the terms of this++License by making exceptions from one or more of its conditions.++Additional permissions that are applicable to the entire Program shall++be treated as though they were included in this License, to the extent++that they are valid under applicable law. If additional permissions++apply only to part of the Program, that part may be used separately++under those permissions, but the entire Program remains governed by++this License without regard to the additional permissions.++++ When you convey a copy of a covered work, you may at your option++remove any additional permissions from that copy, or from any part of++it. (Additional permissions may be written to require their own++removal in certain cases when you modify the work.) You may place++additional permissions on material, added by you to a covered work,++for which you have or can give appropriate copyright permission.++++ Notwithstanding any other provision of this License, for material you++add to a covered work, you may (if authorized by the copyright holders of++that material) supplement the terms of this License with terms:++++ a) Disclaiming warranty or limiting liability differently from the++ terms of sections 15 and 16 of this License; or++++ b) Requiring preservation of specified reasonable legal notices or++ author attributions in that material or in the Appropriate Legal++ Notices displayed by works containing it; or++++ c) Prohibiting misrepresentation of the origin of that material, or++ requiring that modified versions of such material be marked in++ reasonable ways as different from the original version; or++++ d) Limiting the use for publicity purposes of names of licensors or++ authors of the material; or++++ e) Declining to grant rights under trademark law for use of some++ trade names, trademarks, or service marks; or++++ f) Requiring indemnification of licensors and authors of that++ material by anyone who conveys the material (or modified versions of++ it) with contractual assumptions of liability to the recipient, for++ any liability that these contractual assumptions directly impose on++ those licensors and authors.++++ All other non-permissive additional terms are considered "further++restrictions" within the meaning of section 10. If the Program as you++received it, or any part of it, contains a notice stating that it is++governed by this License along with a term that is a further++restriction, you may remove that term. If a license document contains++a further restriction but permits relicensing or conveying under this++License, you may add to a covered work material governed by the terms++of that license document, provided that the further restriction does++not survive such relicensing or conveying.++++ If you add terms to a covered work in accord with this section, you++must place, in the relevant source files, a statement of the++additional terms that apply to those files, or a notice indicating++where to find the applicable terms.++++ Additional terms, permissive or non-permissive, may be stated in the++form of a separately written license, or stated as exceptions;++the above requirements apply either way.++++ 8. Termination.++++ You may not propagate or modify a covered work except as expressly++provided under this License. Any attempt otherwise to propagate or++modify it is void, and will automatically terminate your rights under++this License (including any patent licenses granted under the third++paragraph of section 11).++++ However, if you cease all violation of this License, then your++license from a particular copyright holder is reinstated (a)++provisionally, unless and until the copyright holder explicitly and++finally terminates your license, and (b) permanently, if the copyright++holder fails to notify you of the violation by some reasonable means++prior to 60 days after the cessation.++++ Moreover, your license from a particular copyright holder is++reinstated permanently if the copyright holder notifies you of the++violation by some reasonable means, this is the first time you have++received notice of violation of this License (for any work) from that++copyright holder, and you cure the violation prior to 30 days after++your receipt of the notice.++++ Termination of your rights under this section does not terminate the++licenses of parties who have received copies or rights from you under++this License. If your rights have been terminated and not permanently++reinstated, you do not qualify to receive new licenses for the same++material under section 10.++++ 9. Acceptance Not Required for Having Copies.++++ You are not required to accept this License in order to receive or++run a copy of the Program. Ancillary propagation of a covered work++occurring solely as a consequence of using peer-to-peer transmission++to receive a copy likewise does not require acceptance. However,++nothing other than this License grants you permission to propagate or++modify any covered work. These actions infringe copyright if you do++not accept this License. Therefore, by modifying or propagating a++covered work, you indicate your acceptance of this License to do so.++++ 10. Automatic Licensing of Downstream Recipients.++++ Each time you convey a covered work, the recipient automatically++receives a license from the original licensors, to run, modify and++propagate that work, subject to this License. You are not responsible++for enforcing compliance by third parties with this License.++++ An "entity transaction" is a transaction transferring control of an++organization, or substantially all assets of one, or subdividing an++organization, or merging organizations. If propagation of a covered++work results from an entity transaction, each party to that++transaction who receives a copy of the work also receives whatever++licenses to the work the party's predecessor in interest had or could++give under the previous paragraph, plus a right to possession of the++Corresponding Source of the work from the predecessor in interest, if++the predecessor has it or can get it with reasonable efforts.++++ You may not impose any further restrictions on the exercise of the++rights granted or affirmed under this License. For example, you may++not impose a license fee, royalty, or other charge for exercise of++rights granted under this License, and you may not initiate litigation++(including a cross-claim or counterclaim in a lawsuit) alleging that++any patent claim is infringed by making, using, selling, offering for++sale, or importing the Program or any portion of it.++++ 11. Patents.++++ A "contributor" is a copyright holder who authorizes use under this++License of the Program or a work on which the Program is based. The++work thus licensed is called the contributor's "contributor version".++++ A contributor's "essential patent claims" are all patent claims++owned or controlled by the contributor, whether already acquired or++hereafter acquired, that would be infringed by some manner, permitted++by this License, of making, using, or selling its contributor version,++but do not include claims that would be infringed only as a++consequence of further modification of the contributor version. For++purposes of this definition, "control" includes the right to grant++patent sublicenses in a manner consistent with the requirements of++this License.++++ Each contributor grants you a non-exclusive, worldwide, royalty-free++patent license under the contributor's essential patent claims, to++make, use, sell, offer for sale, import and otherwise run, modify and++propagate the contents of its contributor version.++++ In the following three paragraphs, a "patent license" is any express++agreement or commitment, however denominated, not to enforce a patent++(such as an express permission to practice a patent or covenant not to++sue for patent infringement). To "grant" such a patent license to a++party means to make such an agreement or commitment not to enforce a++patent against the party.++++ If you convey a covered work, knowingly relying on a patent license,++and the Corresponding Source of the work is not available for anyone++to copy, free of charge and under the terms of this License, through a++publicly available network server or other readily accessible means,++then you must either (1) cause the Corresponding Source to be so++available, or (2) arrange to deprive yourself of the benefit of the++patent license for this particular work, or (3) arrange, in a manner++consistent with the requirements of this License, to extend the patent++license to downstream recipients. "Knowingly relying" means you have++actual knowledge that, but for the patent license, your conveying the++covered work in a country, or your recipient's use of the covered work++in a country, would infringe one or more identifiable patents in that++country that you have reason to believe are valid.++++ If, pursuant to or in connection with a single transaction or++arrangement, you convey, or propagate by procuring conveyance of, a++covered work, and grant a patent license to some of the parties++receiving the covered work authorizing them to use, propagate, modify++or convey a specific copy of the covered work, then the patent license++you grant is automatically extended to all recipients of the covered++work and works based on it.++++ A patent license is "discriminatory" if it does not include within++the scope of its coverage, prohibits the exercise of, or is++conditioned on the non-exercise of one or more of the rights that are++specifically granted under this License. You may not convey a covered++work if you are a party to an arrangement with a third party that is++in the business of distributing software, under which you make payment++to the third party based on the extent of your activity of conveying++the work, and under which the third party grants, to any of the++parties who would receive the covered work from you, a discriminatory++patent license (a) in connection with copies of the covered work++conveyed by you (or copies made from those copies), or (b) primarily++for and in connection with specific products or compilations that++contain the covered work, unless you entered into that arrangement,++or that patent license was granted, prior to 28 March 2007.++++ Nothing in this License shall be construed as excluding or limiting++any implied license or other defenses to infringement that may++otherwise be available to you under applicable patent law.++++ 12. No Surrender of Others' Freedom.++++ If conditions are imposed on you (whether by court order, agreement or++otherwise) that contradict the conditions of this License, they do not++excuse you from the conditions of this License. If you cannot convey a++covered work so as to satisfy simultaneously your obligations under this++License and any other pertinent obligations, then as a consequence you may++not convey it at all. For example, if you agree to terms that obligate you++to collect a royalty for further conveying from those to whom you convey++the Program, the only way you could satisfy both those terms and this++License would be to refrain entirely from conveying the Program.++++ 13. Remote Network Interaction; Use with the GNU General Public License.++++ Notwithstanding any other provision of this License, if you modify the++Program, your modified version must prominently offer all users++interacting with it remotely through a computer network (if your version++supports such interaction) an opportunity to receive the Corresponding++Source of your version by providing access to the Corresponding Source++from a network server at no charge, through some standard or customary++means of facilitating copying of software. This Corresponding Source++shall include the Corresponding Source for any work covered by version 3++of the GNU General Public License that is incorporated pursuant to the++following paragraph.++++ Notwithstanding any other provision of this License, you have++permission to link or combine any covered work with a work licensed++under version 3 of the GNU General Public License into a single++combined work, and to convey the resulting work. The terms of this++License will continue to apply to the part which is the covered work,++but the work with which it is combined will remain governed by version++3 of the GNU General Public License.++++ 14. Revised Versions of this License.++++ The Free Software Foundation may publish revised and/or new versions of++the GNU Affero General Public License from time to time. Such new versions++will be similar in spirit to the present version, but may differ in detail to++address new problems or concerns.++++ Each version is given a distinguishing version number. If the++Program specifies that a certain numbered version of the GNU Affero General++Public License "or any later version" applies to it, you have the++option of following the terms and conditions either of that numbered++version or of any later version published by the Free Software++Foundation. If the Program does not specify a version number of the++GNU Affero General Public License, you may choose any version ever published++by the Free Software Foundation.++++ If the Program specifies that a proxy can decide which future++versions of the GNU Affero General Public License can be used, that proxy's++public statement of acceptance of a version permanently authorizes you++to choose that version for the Program.++++ Later license versions may give you additional or different++permissions. However, no additional obligations are imposed on any++author or copyright holder as a result of your choosing to follow a++later version.++++ 15. Disclaimer of Warranty.++++ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY++APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT++HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY++OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,++THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR++PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM++IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF++ALL NECESSARY SERVICING, REPAIR OR CORRECTION.++++ 16. Limitation of Liability.++++ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING++WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS++THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY++GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE++USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF++DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD++PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),++EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF++SUCH DAMAGES.++++ 17. Interpretation of Sections 15 and 16.++++ If the disclaimer of warranty and limitation of liability provided++above cannot be given local legal effect according to their terms,++reviewing courts shall apply local law that most closely approximates++an absolute waiver of all civil liability in connection with the++Program, unless a warranty or assumption of liability accompanies a++copy of the Program in return for a fee.++++ END OF TERMS AND CONDITIONS++++ How to Apply These Terms to Your New Programs++++ If you develop a new program, and you want it to be of the greatest++possible use to the public, the best way to achieve this is to make it++free software which everyone can redistribute and change under these terms.++++ To do so, attach the following notices to the program. It is safest++to attach them to the start of each source file to most effectively++state the exclusion of warranty; and each file should have at least++the "copyright" line and a pointer to where the full notice is found.++++ <one line to give the program's name and a brief idea of what it does.>++ Copyright (C) <year> <name of author>++++ This program is free software: you can redistribute it and/or modify++ it under the terms of the GNU Affero General Public License as published++ by the Free Software Foundation, either version 3 of the License, or++ (at your option) any later version.++++ This program is distributed in the hope that it will be useful,++ but WITHOUT ANY WARRANTY; without even the implied warranty of++ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the++ GNU Affero General Public License for more details.++++ You should have received a copy of the GNU Affero General Public License++ along with this program. If not, see <https://www.gnu.org/licenses/>.++++Also add information on how to contact you by electronic and paper mail.++++ If your software can interact with users remotely through a computer++network, you should also make sure that it provides a way for users to++get its source. For example, if your program is a web application, its++interface could display a "Source" link that leads users to an archive++of the code. There are many ways you could offer source, and different++solutions will be better for different programs; see section 13 for the++specific requirements.++++ You should also get your employer (if you work as a programmer) or school,++if any, to sign a "copyright disclaimer" for the program, if necessary.++For more information on this, and how to apply and follow the GNU AGPL, see++<https://www.gnu.org/licenses/>.nnew file mode 100644@@@ -0,0 +1,160 @@++# OpenVibe.Shoes++++> Every shoe, every market, the right price.++++**Status:** v1 written and tested (see [STATUS.json](STATUS.json)).++**Domain:** `open.shoes` · **Port:** 5180 · **Service id:** `shoes` · **Env prefix:** `SHOES`++**License:** AGPL-3.0-only (same as every OpenVibe service).++++OpenVibe.Shoes is the network's shoe resource: one page per model (`/shoe/<brand>-<model>`) with every store's++offer in one all-in price, a price history from Shoes' own daily snapshots, the size-price curve where sizes are++known, and cited specs, fit notes, release facts and care notes — or an honest "not known yet". A person can ask++to be told when the model drops below a price at their size, or paste any listing link and have OpenVibe.Watch++keep it. Official release dates are a drops calendar with "remind me" on OpenVibe.Today. Shoes never enters a++raffle or a draw, never scrapes Facebook Marketplace, and carries no affiliate links.++++## What it reads++++| Source | What | Key |++|---|---|---|++| **OpenVibe.Deals** | the network's own offer aggregator: its public newest-offer pages and each product's public record (brand). No key. | no |++| **eBay Browse API** | official item summaries, only when a developer key pair is set | `SHOES_EBAY_CLIENT_ID` + `SHOES_EBAY_CLIENT_SECRET` |++| **A brand's or a store's own release page** | the official release calendar (Nike SNKRS by default), parsed by a pure parser that never invents a date | no |++| **OpenVibe.Watch** | a watch for a person (service token; the person in `X-OV-Subject`) | `SHOES_WATCH_TOKEN` |++| **OpenVibe.Today** | a drop reminder on the person's own agenda (their Network token) | the person's session |++++Every offer keeps its store, its currency, the price, the shipping the store stated, the condition, the size and++colourway when the source names them, the source's own check time and a stale flag. Every price is **all-in**:++price + shipping when shipping is stated. A missing key turns that part off with a plain note; it is never an++error. No source is a scrape: Deals reads stores through the network's sources, eBay is the official API, and a++pasted Marketplace link is handed to Watch, which applies its own fetch rules.++++## Pages++++| Route | What it shows |++|---|---|++| `/` | the room's front door: hero, live proof, the biggest price drops today, dropping this week, popular models, the three scenes (a size-price curve, a drop reminder on Today, a pasted link), explore, developers, questions |++| `/shoes?q=&brand=&size=&condition=&sort=&page=` | the catalogue with facets; every offer all-in and dated |++| `/shoe/:slug` | one model: offers by store, the daily price history (SVG from Shoes' own snapshots), the size-price curve, colourways, cited facts or "not known yet", and the watch form |++| `/drops` | the official release calendar, grouped by day, with "remind me" (on Today) and the never-automated-entries note |++| `/track` | paste a listing link; it becomes a Watch |++| `/watches` | a signed-in person's watches (private, no-store) |++| `/developers` | the API, sources and examples |++| `/api/openapi.json` | the OpenAPI 3.1 document |++++All pages are server-rendered through `openvibe-shared/shell` and the room kit (`rooms.js`, `kit.card`,++`kit.rail`, `sc.room`), with the room id `shoes` and the accent `#FF7E76` (dark) / `#B02A2D` (light). Every page is++complete without JavaScript; forms are plain POSTs.++++## API++++Everything under `/api/v1`; errors are `application/problem+json` with a `code`. Public reads take no key.++++| Route | Answer |++|---|---|++| `GET /api/v1/shoes?q&brand&size&condition&store&sort&page&limit` | `{ shoes, facets, page, per_page, total, sort, filters }`; sorts `recent`, `drop`, `price`, `price_desc`, `popular`, `name` |++| `GET /api/v1/shoes/:slug` | `{ shoe, offers, history, curve, facts, drops }` |++| `GET /api/v1/shoes/:slug/price?size=&condition=` | `{ slug, size, condition, price, currency, checked_at, offers }` — the URL a Watch polls |++| `GET /api/v1/drops?from&to` | `{ from, to, drops }`, soonest first |++| `GET /api/v1/drops/:id` | one drop |++| `GET /api/v1/me/watches` | session only: the person's watches |++| `POST /api/v1/me/watches` | session only: `{ slug, size?, condition?, threshold, currency? }` → a price watch in OpenVibe.Watch |++| `POST /api/v1/me/track` | session only: `{ url }` → a Watch for a pasted listing, through Watch's own rules |++| `GET /api/v1/status` | the last read of each source, the counts, and whether eBay/Watch are configured |++| `GET /api/openapi.json` | OpenAPI 3.1 |++++A signed-in write made with the session cookie must come from `open.shoes` (`Sec-Fetch-Site: same-origin`, or an++`Origin` equal to the site): another site cannot set a watch for a visitor. A bad Bearer is refused, never++downgraded to the session or anonymous.++++## Jobs (`server/jobs/*`)++++Each refuses to start under `SHOES_DRILL=1`; tests and `scripts/seed.js` call their `runOnce()`.++++| Job | Reads | Writes | Default |++|---|---|---|---|++| `offers` | OpenVibe.Deals' public newest offers, then each candidate's public product for its brand | a model, an offer with its all-in inputs, one snapshot per offer per day | every 30 min, 20 pages |++| `drops` | the official release calendar and any configured RSS feeds | a drop with its source page and read time | every 6 h |++| `ebay` | the official Browse API (only with a key pair) | an offer, the same as any other source | every 60 min, off without keys |++++A job records each run in `shoe_job_runs` (source, start, finish, counts, error); `/api/v1/status` and the home FAQ++show it. The importer stops at the first page Deals answers empty, so a quiet source costs nothing.++++## Database++++PostgreSQL through `openvibe-sdk/db`, numbered migrations (`migrations/0001_initial.sql`). The tests use PGlite;++`npm run test:pg` runs the same suite through PgBouncer.++++| Table | Holds |++|---|---|++| `shoe_models` | `shm_` + ULID, unique slug, brand, model, first seen |++| `shoe_offers` | `sho_` + ULID, source + source id (unique), store, title, URL, price, list price, shipping, condition, size, colourway, checked time, stale, raw public item |++| `shoe_snapshots` | `shs_` + ULID, offer, model, price, checked time, day — unique per offer per day (the first check wins) |++| `shoe_drops` | `shd_` + ULID, unique slug, release time, price, sizes, source name/URL/key, read time |++| `shoe_watches` | `shw_` + ULID, subject, kind `price\|link`, model, size, condition, threshold, source URL, remote Watch id, status, note |++| `shoe_facts` | cited specs/fit/release/care: kind, label, value, source name/URL, read time |++| `shoe_job_runs` | one row per job run: source, start, finish, ok, counts, error |++| `account_data_events`, `telemetry_samples` | the SDK's account receipts and request telemetry |++++## Environment++++Only names in code and docs; values live in `/etc/openvibe/shoes.env` (see [.env.example](.env.example)).++++- **Service:** `NODE_ENV`, `PORT` (5180), `HOST`, `BASE_URL`, `TRUST_PROXY`, `COOKIE_SECURE`, `SHOES_DRILL`.++- **Database:** `DATABASE_URL`, `DATABASE_DIRECT_URL`, `SHOES_PGLITE_DIR`, `VALKEY_URL`, `VALKEY_PREFIX`.++- **Network:** `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_NETWORK_ISSUER`, `OV_OAUTH_CLIENT_ID`,++ `OV_OAUTH_CLIENT_SECRET`, `OV_OAUTH_REDIRECT_URI`, `OV_SESSION_AUDIENCE`, `SHOES_AUDIENCE`.++- **Events:** `SHOES_EVENTS_SECRET`, `SHOES_EVENTS_URL`, `SHOES_EVENTS_ENDPOINT`, `SHOES_EVENTS_INTERVAL_MS`.++- **Sources:** `SHOES_DEALS_URL`, `SHOES_OFFERS_PAGES`, `SHOES_PRODUCT_LOOKUPS`, `SHOES_STALE_HOURS`,++ `SHOES_UPSTREAM_TIMEOUT_MS`, `SHOES_REQUEST_GAP_MS`, `SHOES_DROPS_URL`, `SHOES_DROPS_SOURCE_NAME`, `SHOES_DROP_FEEDS`,++ `SHOES_EBAY_CLIENT_ID`, `SHOES_EBAY_CLIENT_SECRET`, `SHOES_EBAY_MARKETPLACE`, `SHOES_EBAY_CATEGORY_ID`,++ `SHOES_EBAY_LIMIT`.++- **Watch:** `SHOES_WATCH_URL`, `SHOES_WATCH_INTERNAL_URL`, `SHOES_WATCH_TOKEN`, `SHOES_WATCH_AUDIENCE`,++ `SHOES_WATCH_CAPABILITY`, `SHOES_WATCH_EVERY_SEC`.++- **Today:** `SHOES_TODAY_URL`, `SHOES_TODAY_INTERNAL_URL`.++- **Jobs:** `SHOES_OFFERS_INTERVAL_MS`, `SHOES_DROPS_INTERVAL_MS`, `SHOES_EBAY_INTERVAL_MS`.++++## Run it++++```bash++npm ci --include=dev++npm test # every unit and HTTP test on PGlite++npm run test:pg # the same through PostgreSQL + PgBouncer++npm run seed # one first run of every job against the live sources (needs the network)++npm start # http://127.0.0.1:5180++```++++Without `DATABASE_URL`, development uses an embedded PGlite database in `data/pglite` (one process only). A++production start needs `DATABASE_URL` and `DATABASE_DIRECT_URL`; the owner role migrates on boot.++++## Deploy++++Copy `deploy/systemd/openvibe-shoes.service` to `/etc/systemd/system/` and++`deploy/nginx/open.shoes.conf` to `/etc/nginx/sites-available/`; the unit reads `/etc/openvibe/shoes.env`, works in++`/opt/openvibe.shoes`, runs as `ubuntu:ubuntu` on `127.0.0.1:5180`, and the vhost proxies `/api/`, `/auth/` and the++forms with their own limits. The `.gitignore` keeps `node_modules`, `data`, `.env` and keys out of the repo.++++## First run (seed)++++`npm run seed` reads the live sources once and prints what it wrote. A source that does not answer is reported as++a plain note and does not fail the other jobs. The first local run (2026-10-31, 60 Deals pages) wrote:++++| Source | Result |++|---|---|++| OpenVibe.Deals | 1,500 offers read, 182 footwear candidates, 165 product lookups, **178 offers** |++| Nike SNKRS launch calendar | **16 official releases** |++| eBay | off (no developer key pair) |++| **Catalogue** | **169 models · 178 offers · 178 daily snapshots · 16 drops · 0 facts** |++++The same counts are in [STATUS.json](STATUS.json) under `seed`. The catalogue opens with real models and real++releases; the price history is one day deep and grows with every daily check, and the "biggest price drops today"++section says so plainly when no store has cut a price yet.++++## What v1 does not do++++- No raffle, draw, reservation or checkout is automated, ever; the drops calendar links the official page and++ adds a reminder on Today.++- Specs/fit/release/care are shown only from cited sources; with none, the page says "not known yet".++- eBay is off until the host sets a developer key pair; Watch is off until the host sets a service token (the++ watch is still saved on Shoes and marked "not connected").++- No affiliate links, no sponsored order, no Marketplace scraping.nnew file mode 100644@@@ -0,0 +1,37 @@++# Security policy++++OpenVibe is run in the open, and we want to hear about security problems before anyone else does.++++## Reporting a vulnerability++++Email **[email protected]** with "security" in the subject. Please include what you found, where (URL,++repository, file), how to reproduce it, and what an attacker could do with it. Do not open a public GitHub issue for++a vulnerability, and do not test against other people's accounts, data or streams.++++We reply within 7 days, keep you updated while we fix it, and credit you when the fix ships if you would like.++++## Scope++++Every OpenVibe service and site (open.shoes, openvibe.live, openvibe.network, openvibe.today, openvibe.tools,++openvibe.media, openvibe.community, openvibe.games and the other openvibe.* domains) and every repository under++github.com/OpenVibers. Machine-readable contact details are at `/.well-known/security.txt` on each site.++++## What OpenVibe.Shoes holds++++- Public models, offers, daily snapshots, drops and cited facts: the stores' public listings, prices, all-in++ inputs, sizes, conditions, colourways, source pages, check times and read times. They are the network's public++ record, not a person's row, so they are not exported or erased with an account.++- A person's watches: the subject id, the kind (a price watch or a pasted link), the model/size/condition, the++ threshold and a public URL. These are exported and erased with an account.++- Network session tokens only in httpOnly cookies (`shoes_at`, `shoes_rt`); no token is stored in the database,++ rendered into a page or logged. The Watch service token and eBay key pair are environment values and never++ stored or logged.++- A pasted URL is checked by `openvibe-shared/egress` before it is handed to Watch; Shoes never fetches the URL++ itself, and Facebook Marketplace is never scraped.++++The `/internal/events` account route is loopback only and refuses a proxied request; nginx answers 404 for++`/internal` and `/metrics`.++++## Supported versions++++Only the current `main` branch, which is what runs in production, receives fixes.nnew file mode 100644@@@ -0,0 +1,99 @@++{++ "repository": "OpenVibers/OpenVibe.Shoes",++ "version": "0.1.0",++ "stage": "product",++ "code": true,++ "plan": "OpenVibe.Shoes: every shoe, every market, the right price — one page per model with every store's all-in price, Shoes' own daily price snapshots, the size-price curve, cited specs, a price watch at a size and paste-a-link tracking through OpenVibe.Watch, and official drops with a reminder on Today",++ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",++ "port": "5180",++ "domain": "open.shoes",++ "contracts": "openvibe-contracts v0.234.0",++ "sdk": "openvibe-sdk v0.70.0",++ "shared": "openvibe-shared v4.31.0",++ "packages": [++ "openvibe-contracts v0.234.0",++ "openvibe-sdk v0.70.0",++ "openvibe-shared v4.31.0"++ ],++ "summary": "OpenVibe.Shoes is the network's shoe resource. It reads OpenVibe.Deals' public offers (and the official eBay Browse API when a key pair is set), keeps the footwear in its own catalogue, and shows one page per model with every store's all-in price, a price history from its own one-snapshot-per-offer-per-day record, the size-price curve where sizes are known, and specs/fit/release/care only from cited open sources. A signed-in person can ask Watch to tell them when the model drops below a price at their size, or paste any listing link and have Watch apply its own fetch rules. Official release dates come from the brands' and stores' own public release pages; a reminder goes on Today. Shoes never enters a raffle, never scrapes Marketplace and carries no affiliate links.",++ "works": [++ "Models and offers with a unique slug, all-in price, list price, condition, size, colourway, source check time and stale flag",++ "One snapshot per offer per day (the first check wins), the price history and the size-price curve from those snapshots",++ "The offers job over OpenVibe.Deals' public API, with the footwear classifier and product brand lookup",++ "The drops job over a brand's or store's own release page, plus optional RSS feeds; every release cited and dated",++ "A price watch at a size and a pasted listing link through OpenVibe.Watch's API (service token + X-OV-Subject)",++ "A drop reminder on OpenVibe.Today with the person's own token; no automated raffle entries",++ "The public API: search with facets, one model with offers/history/curve/facts, the price a Watch polls, drops, the person's watches, source status, OpenAPI 3.1",++ "The home via sc.room: hero, proof, biggest price drops today, dropping this week, popular models, three scenes, explore, developers, FAQ, network",++ "Server-rendered pages, discovery artifacts, cache headers, account export/deletion and deploy files",++ "Tests on PGlite and PostgreSQL: pure rules, the catalogue, the job parsers, every HTTP route"++ ],++ "notYet": [++ "eBay is off until the host sets SHOES_EBAY_CLIENT_ID and SHOES_EBAY_CLIENT_SECRET",++ "OpenVibe.Watch is off until the host sets SHOES_WATCH_TOKEN; a watch is then saved on Shoes and marked not_connected",++ "Specs, fit notes and care notes are empty until a cited open source is added to the shoe_facts table; the pages say not known yet",++ "The size-price curve is empty until a source states the size; no size is invented",++ "No closet, image search or merchant inventory feed (plan v2/v3)",++ "The drops parser knows the Nike SNKRS launch payload and plain RSS; other calendars need their own parser"++ ],++ "capabilitiesRegistered": false,++ "serviceManifestRegistered": false,++ "deployed": false,++ "placeholderInSites": false,++ "seed": {++ "run_at": "2026-10-31T22:34:27Z",++ "sources": {++ "offers": {++ "ok": true,++ "pages": 60,++ "seen": 1500,++ "candidates": 182,++ "product_lookups": 165,++ "offers": 178,++ "skipped": 4++ },++ "drops": {++ "ok": true,++ "sources": 1,++ "releases": 16,++ "drops": 16,++ "skipped": 0++ },++ "ebay": {++ "ok": true,++ "configured": false,++ "offers": 0,++ "note": "off: no developer key pair"++ }++ },++ "counts": {++ "models": 169,++ "offers": 178,++ "snapshots": 178,++ "drops": 16,++ "watches": 0,++ "facts": 0++ }++ },++ "features": [++ "models and offers",++ "all-in price with stale flag",++ "daily price snapshots and history",++ "size-price curve",++ "cited facts or not known yet",++ "offers job",++ "drops job",++ "eBay job (off without keys)",++ "watch at a size",++ "paste-a-link tracking",++ "drop reminders on Today",++ "public API and OpenAPI",++ "account export and deletion",++ "Network sign-in",++ "server-rendered pages",++ "discovery",++ "deploy files"++ ],++ "deployment": "not deployed: the unit and nginx vhost are deploy/systemd/openvibe-shoes.service and deploy/nginx/open.shoes.conf",++ "updated": "2026-10-31"++}nnew file mode 100644@@@ -0,0 +1,115 @@++## ═══════════════════════════════════════════════════════════════++## open.shoes — Nginx configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/open.shoes.conf (ov-vhost-install) — keep them in sync and reload++## nginx after changing it. The Node app (port 5180, systemd unit openvibe-shoes) renders every page and serves++## the API; nginx terminates TLS, redirects www → apex, rate-limits sign-in, the API and the forms, and compresses++## text.++##++## No proxy_cache: the app decides cacheability per response (public for the model pages and the catalogue; private,++## no-store for anything drawn for a signed-in person). Never log request bodies here.++## ═══════════════════════════════════════════════════════════════++++limit_req_zone $binary_remote_addr zone=shoes_auth:10m rate=10r/m;++limit_req_zone $binary_remote_addr zone=shoes_api:10m rate=5r/s;++limit_req_zone $binary_remote_addr zone=shoes_forms:10m rate=1r/s;++++server {++ listen 80;++ listen [::]:80;++ server_name open.shoes www.open.shoes;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://open.shoes$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.open.shoes;++++ ssl_certificate /etc/letsencrypt/live/open.shoes/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/open.shoes/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ return 301 https://open.shoes$request_uri;++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name open.shoes;++++ ssl_certificate /etc/letsencrypt/live/open.shoes/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/open.shoes/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/open.shoes.access.log;++ error_log /var/log/nginx/open.shoes.error.log;++++ # The API's JSON body cap is 64 KB; leave room for the headers.++ client_max_body_size 128k;++++ add_header X-Content-Type-Options nosniff always;++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++++ gzip on;++ gzip_proxied any;++ gzip_vary on;++ gzip_comp_level 5;++ gzip_min_length 1024;++ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;++++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++++ # ── Network SSO session endpoints ───────────────────────++ # The navbar's session probe runs on every page view: it gets the API's budget, not sign-in's.++ location = /auth/me {++ limit_req zone=shoes_api burst=30 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5180;++ }++++ location /auth/ {++ limit_req zone=shoes_auth burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5180;++ }++++ # ── API ─────────────────────────────────────────────────++ location /api/ {++ limit_req zone=shoes_api burst=20 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5180;++ }++++ # ── Forms and other writes: the price watch, the pasted link, the drop reminder ──++ location ~ ^/(shoe/[a-z0-9-]+/watch|track|drops/[^/]+/remind)$ {++ limit_req zone=shoes_forms burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5180;++ }++++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5180/metrics) ──++ location ~* ^/metrics(/|$) { return 404; }++++ # ── OpenVibe.Events' account delivery route: loopback only (POST /internal/events) ──++ # Events posts to http://127.0.0.1:5180/internal/events directly. Never proxied from outside (ADR-033).++ location ~* ^/internal(/|$) { return 404; }++++ # ── Pages, docs, robots.txt, sitemap, static assets ─────++ location / {++ proxy_pass http://127.0.0.1:5180;++ }++++ location ~ /\.(?!well-known) {++ deny all;++ }++}nnew file mode 100644@@@ -0,0 +1,31 @@++[Unit]++Description=OpenVibe.Shoes — every shoe, every market, the right price.++# Network (sign-in, JWKS, account events) should be up first, but Shoes degrades honestly without it: the public++# pages and API serve, and sign-in and the guarded routes wait for the signing key (see /api/ready).++After=network.target postgresql.service pgbouncer.service valkey-server.service++Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/openvibe.shoes++EnvironmentFile=/etc/openvibe/shoes.env++Environment=NODE_ENV=production++Environment=PORT=5180++Environment=HOST=127.0.0.1++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.shoes/server/index.js++Restart=always++RestartSec=5++KillSignal=SIGTERM++# In-flight requests get the 10 s drain.++TimeoutStopSec=15++NoNewPrivileges=true++PrivateTmp=true++ProtectSystem=strict++ProtectHome=true++# OpenVibe.Shoes' own database is PostgreSQL on the host's data role; the code tree stays read-only.++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,195 @@++-- phase: expand++-- OpenVibe.Shoes: the shoe catalogue (open.shoes). Every offer comes from an official source with the time it was++-- checked; every price history point is one snapshot per offer per day; every drop cites the release page it came++-- from; every spec or care note is cited to the open source that said it, or the page says "not known yet".++-- Nothing here is a secret: a watch holds a person's subject id, a threshold and the public URL they pasted.++++-- One model: a brand and a model name ("Nike" + "Air Max 90"). The slug is the page: /shoe/<brand>-<model>.++CREATE TABLE shoe_models (++ id text COLLATE "C" PRIMARY KEY, -- shm_ + 26 Crockford base32 characters (ULID)++ slug text COLLATE "C" NOT NULL UNIQUE, -- /shoe/<brand>-<model>++ brand text COLLATE "C" NOT NULL,++ model text COLLATE "C" NOT NULL,++ name text COLLATE "C" NOT NULL, -- the model's name as its source wrote it++ first_seen_at timestamptz NOT NULL,++ updated_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT shoe_models_id CHECK (id ~ '^shm_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT shoe_models_slug CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$' AND length(slug) <= 80),++ CONSTRAINT shoe_models_brand CHECK (length(brand) BETWEEN 1 AND 80),++ CONSTRAINT shoe_models_model CHECK (length(model) BETWEEN 1 AND 120),++ CONSTRAINT shoe_models_unique UNIQUE (brand, model)++);++CREATE INDEX shoe_models_brand ON shoe_models (brand);++++-- One offer: a store's listing of one model, with the price, the shipping when the store states it, the list price++-- when the source gives one, the condition, the size when the source names one, the time the source was checked and++-- a stale flag. raw holds the source's own item (public, no credentials).++CREATE TABLE shoe_offers (++ id text COLLATE "C" PRIMARY KEY, -- sho_ + 26 Crockford base32 characters (ULID)++ model_id text COLLATE "C" NOT NULL REFERENCES shoe_models (id) ON DELETE CASCADE,++ source text COLLATE "C" NOT NULL, -- the source that published it (deals | ebay)++ source_id text COLLATE "C" NOT NULL, -- that source's own id++ store_domain text COLLATE "C" NOT NULL,++ store_name text,++ title text COLLATE "C" NOT NULL,++ url text NOT NULL, -- the store's own page for the listing++ price_amount numeric(10,2), -- the price as the source stated it++ list_price_amount numeric(10,2), -- the source's own "was" price, when it states one++ currency text COLLATE "C" NOT NULL DEFAULT 'USD',++ shipping_amount numeric(10,2), -- null when the store states no shipping++ condition text COLLATE "C", -- new | used | deadstock | null (not stated)++ size text COLLATE "C", -- the size, when the source names one++ colourway text COLLATE "C",++ checked_at timestamptz NOT NULL, -- when the source was checked++ stale boolean NOT NULL DEFAULT false,++ active boolean NOT NULL DEFAULT true,++ raw jsonb NOT NULL DEFAULT '{}'::jsonb, -- the source's own public item++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT shoe_offers_id CHECK (id ~ '^sho_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT shoe_offers_unique UNIQUE (source, source_id),++ CONSTRAINT shoe_offers_price CHECK (price_amount IS NULL OR price_amount >= 0),++ CONSTRAINT shoe_offers_list_price CHECK (list_price_amount IS NULL OR list_price_amount >= 0),++ CONSTRAINT shoe_offers_condition CHECK (condition IS NULL OR condition IN ('new', 'used', 'deadstock')),++ CONSTRAINT shoe_offers_url CHECK (url ~ '^https?://')++);++CREATE INDEX shoe_offers_model ON shoe_offers (model_id, active);++CREATE INDEX shoe_offers_store ON shoe_offers (store_domain);++CREATE INDEX shoe_offers_checked ON shoe_offers (checked_at DESC);++CREATE INDEX shoe_offers_size ON shoe_offers (model_id, size) WHERE size IS NOT NULL;++++-- The price history: one snapshot per offer per day (the first check of that day wins).++CREATE TABLE shoe_snapshots (++ id text COLLATE "C" PRIMARY KEY, -- shs_ + 26 Crockford base32 characters (ULID)++ offer_id text COLLATE "C" NOT NULL REFERENCES shoe_offers (id) ON DELETE CASCADE,++ model_id text COLLATE "C" NOT NULL REFERENCES shoe_models (id) ON DELETE CASCADE,++ price_amount numeric(10,2),++ shipping_amount numeric(10,2), -- the shipping the offer stated that day (null: not stated)++ checked_at timestamptz NOT NULL,++ day date NOT NULL,++ created_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT shoe_snapshots_id CHECK (id ~ '^shs_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT shoe_snapshots_unique UNIQUE (offer_id, day)++);++CREATE INDEX shoe_snapshots_model_day ON shoe_snapshots (model_id, day DESC);++++-- A drop: an official release date, with the page it was read from and the time it was read.++CREATE TABLE shoe_drops (++ id text COLLATE "C" PRIMARY KEY, -- shd_ + 26 Crockford base32 characters (ULID)++ slug text COLLATE "C" NOT NULL UNIQUE,++ brand text COLLATE "C" NOT NULL,++ model text COLLATE "C" NOT NULL,++ colourway text COLLATE "C",++ release_at timestamptz NOT NULL,++ price_amount numeric(10,2),++ currency text COLLATE "C" NOT NULL DEFAULT 'USD',++ sizes jsonb NOT NULL DEFAULT '[]'::jsonb, -- the size grid the source published++ source_name text COLLATE "C" NOT NULL, -- who published the release date++ source_url text NOT NULL, -- the release page itself++ source_key text COLLATE "C" NOT NULL, -- that source's own key for the drop++ fetched_at timestamptz NOT NULL, -- when we read it++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT shoe_drops_id CHECK (id ~ '^shd_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT shoe_drops_slug CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$' AND length(slug) <= 80),++ CONSTRAINT shoe_drops_url CHECK (source_url ~ '^https?://'),++ CONSTRAINT shoe_drops_unique UNIQUE (source_name, source_key)++);++CREATE INDEX shoe_drops_release ON shoe_drops (release_at);++++-- A person's watches: a price watch (this model, this size, below this price) or a pasted listing link.++-- The watch itself lives in OpenVibe.Watch; this row is what Shoes shows and links.++CREATE TABLE shoe_watches (++ id text COLLATE "C" PRIMARY KEY, -- shw_ + 26 Crockford base32 characters (ULID)++ subject text COLLATE "C" NOT NULL, -- usr_ + 26 Crockford base32 characters++ kind text COLLATE "C" NOT NULL, -- price | link++ model_id text COLLATE "C" REFERENCES shoe_models (id) ON DELETE SET NULL,++ slug text COLLATE "C",++ size text COLLATE "C",++ condition text COLLATE "C",++ threshold_amount numeric(10,2),++ currency text COLLATE "C" NOT NULL DEFAULT 'USD',++ source_url text,++ remote_id text, -- the watch id in OpenVibe.Watch++ status text COLLATE "C" NOT NULL DEFAULT 'active', -- active | not_connected | failed++ note text,++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT shoe_watches_id CHECK (id ~ '^shw_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT shoe_watches_kind CHECK (kind IN ('price', 'link')),++ CONSTRAINT shoe_watches_status CHECK (status IN ('active', 'not_connected', 'failed')),++ CONSTRAINT shoe_watches_threshold CHECK (threshold_amount IS NULL OR threshold_amount > 0),++ CONSTRAINT shoe_watches_source_url CHECK (source_url IS NULL OR source_url ~ '^https?://')++);++CREATE INDEX shoe_watches_subject ON shoe_watches (subject, created_at DESC);++++-- A cited open fact: a spec, a fit note, a release fact or a care note. Only ever inserted with its source;++-- a model with no rows says "not known yet" instead of guessing.++CREATE TABLE shoe_facts (++ id text COLLATE "C" PRIMARY KEY, -- shf_ + 26 Crockford base32 characters (ULID)++ model_id text COLLATE "C" NOT NULL REFERENCES shoe_models (id) ON DELETE CASCADE,++ kind text COLLATE "C" NOT NULL, -- specs | fit | release | care++ label text COLLATE "C" NOT NULL,++ value text NOT NULL,++ source_name text COLLATE "C" NOT NULL,++ source_url text NOT NULL,++ fetched_at timestamptz NOT NULL,++ created_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT shoe_facts_id CHECK (id ~ '^shf_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT shoe_facts_kind CHECK (kind IN ('specs', 'fit', 'release', 'care')),++ CONSTRAINT shoe_facts_url CHECK (source_url ~ '^https?://')++);++CREATE INDEX shoe_facts_model ON shoe_facts (model_id, kind);++++-- What each job did, so a page or a person can say when a source was last read and whether it answered.++CREATE TABLE shoe_job_runs (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ job text COLLATE "C" NOT NULL, -- offers | drops | ebay++ source_name text COLLATE "C" NOT NULL,++ source_url text,++ started_at timestamptz NOT NULL,++ finished_at timestamptz,++ ok boolean,++ counts jsonb NOT NULL DEFAULT '{}'::jsonb,++ error text++);++CREATE INDEX shoe_job_runs_job ON shoe_job_runs (job, started_at DESC);++++-- openvibe-sdk/account-data keeps its own idempotency here (ADR-033): a redelivered export or deletion changes++-- nothing. It holds an export_id or deletion_id, the subject and the counts sent to Network; it is not a secret.++CREATE TABLE account_data_events (++ id text PRIMARY KEY,++ kind text NOT NULL,++ subject text NOT NULL,++ outcome jsonb,++ applied_at timestamptz NOT NULL DEFAULT now(),++ sent_at timestamptz++);++++-- Request telemetry (openvibe-sdk/telemetry): one row per aggregated sample, pruned after 14 days.++CREATE TABLE telemetry_samples (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ at timestamptz NOT NULL,++ service text NOT NULL,++ project text,++ subject text,++ resource text,++ provider text,++ node text,++ cell text,++ region text,++ operation text NOT NULL,++ latency_ms double precision,++ queue_delay_ms double precision,++ ttfb_ms double precision,++ throughput_per_second double precision,++ bytes bigint,++ status text,++ cache_status text,++ cost_estimate double precision,++ route_epoch bigint,++ trace_id text,++ extra jsonb NOT NULL DEFAULT '{}'::jsonb++);++CREATE INDEX telemetry_samples_at_idx ON telemetry_samples (at);++CREATE INDEX telemetry_samples_service_idx ON telemetry_samples (service, operation, at);nnew file mode 100644@@@ -0,0 +1,1407 @@++{++ "name": "openvibe-shoes",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-shoes",++ "version": "0.1.0",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "express-rate-limit": "^7.5.0",++ "helmet": "^8.1.0",++ "iovalkey": "^0.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.265.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",++ "pg": "^8.23.1"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"++ },++ "engines": {++ "node": ">=22.0.0"++ }++ },++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.13",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",++ "integrity": "sha512-4rZuxK1k07w6sSpJzjMri0B9+HuXuC7V9pxVB51pR0T9KPqXg6TD0mdaEwBA9rkcL4jdht4tGDf/wYzAiGhT7g==",++ "devOptional": true,++ "license": "Apache-2.0"++ },++ "node_modules/@iovalkey/commands": {++ "version": "0.1.0",++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",++ "license": "MIT"++ },++ "node_modules/accepts": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",++ "license": "MIT",++ "dependencies": {++ "mime-types": "~2.1.34",++ "negotiator": "0.6.3"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/ajv": {++ "version": "8.20.0",++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",++ "license": "MIT",++ "dependencies": {++ "fast-deep-equal": "^3.1.3",++ "fast-uri": "^3.0.1",++ "json-schema-traverse": "^1.0.0",++ "require-from-string": "^2.0.2"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/epoberezkin"++ }++ },++ "node_modules/ajv-formats": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",++ "license": "MIT",++ "dependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependenciesMeta": {++ "ajv": {++ "optional": true++ }++ }++ },++ "node_modules/array-flatten": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",++ "license": "MIT"++ },++ "node_modules/body-parser": {++ "version": "1.20.9",++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz",++ "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "content-type": "~1.0.5",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "~1.2.0",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "on-finished": "~2.4.1",++ "qs": "~6.16.0",++ "raw-body": "~2.5.3",++ "type-is": "~1.6.18",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/buffer-equal-constant-time": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",++ "license": "BSD-3-Clause"++ },++ "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/cluster-key-slot": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/content-disposition": {++ "version": "0.5.4",++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "5.2.1"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "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-parser": {++ "version": "1.4.7",++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",++ "license": "MIT",++ "dependencies": {++ "cookie": "0.7.2",++ "cookie-signature": "1.0.6"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/cookie-signature": {++ "version": "1.0.6",++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",++ "license": "MIT"++ },++ "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/denque": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10"++ }++ },++ "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/destroy": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/dotenv": {++ "version": "16.6.1",++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=12"++ },++ "funding": {++ "url": "https://dotenvx.com"++ }++ },++ "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/ecdsa-sig-formatter": {++ "version": "1.0.11",++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ }++ },++ "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": "4.22.3",++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",++ "license": "MIT",++ "dependencies": {++ "accepts": "~1.3.8",++ "array-flatten": "1.1.1",++ "body-parser": "~1.20.5",++ "content-disposition": "~0.5.4",++ "content-type": "~1.0.4",++ "cookie": "~0.7.1",++ "cookie-signature": "~1.0.6",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "finalhandler": "~1.3.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.0",++ "merge-descriptors": "1.0.3",++ "methods": "~1.1.2",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "path-to-regexp": "~0.1.13",++ "proxy-addr": "~2.0.7",++ "qs": "~6.16.0",++ "range-parser": "~1.2.1",++ "safe-buffer": "5.2.1",++ "send": "~0.19.0",++ "serve-static": "~1.16.2",++ "setprototypeof": "1.2.0",++ "statuses": "~2.0.1",++ "type-is": "~1.6.18",++ "utils-merge": "1.0.1",++ "vary": "~1.1.2"++ },++ "engines": {++ "node": ">= 0.10.0"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/express-rate-limit": {++ "version": "7.5.1",++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",++ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",++ "license": "MIT",++ "engines": {++ "node": ">= 16"++ },++ "funding": {++ "url": "https://github.com/sponsors/express-rate-limit"++ },++ "peerDependencies": {++ "express": ">= 4.11"++ }++ },++ "node_modules/fast-deep-equal": {++ "version": "3.1.3",++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",++ "license": "MIT"++ },++ "node_modules/fast-uri": {++ "version": "3.1.9",++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",++ "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fastify"++ },++ {++ "type": "opencollective",++ "url": "https://opencollective.com/fastify"++ }++ ],++ "license": "BSD-3-Clause"++ },++ "node_modules/finalhandler": {++ "version": "1.3.2",++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "statuses": "~2.0.2",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "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": "0.5.2",++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "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/helmet": {++ "version": "8.3.0",++ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",++ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",++ "license": "MIT",++ "engines": {++ "node": ">=18.0.0"++ },++ "funding": {++ "url": "https://github.com/sponsors/EvanHahn"++ }++ },++ "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.4.24",++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",++ "license": "MIT",++ "dependencies": {++ "safer-buffer": ">= 2.1.2 < 3"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "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/iovalkey": {++ "version": "0.4.0",++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",++ "license": "MIT",++ "dependencies": {++ "@iovalkey/commands": "^0.1.0",++ "cluster-key-slot": "^1.1.0",++ "debug": "^4.3.4",++ "denque": "^2.1.0",++ "lodash.defaults": "^4.2.0",++ "lodash.isarguments": "^3.1.0",++ "redis-errors": "^1.2.0",++ "redis-parser": "^3.0.0",++ "standard-as-callback": "^2.1.0"++ },++ "engines": {++ "node": ">=18.12.0"++ }++ },++ "node_modules/iovalkey/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/iovalkey/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/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/json-schema-traverse": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",++ "license": "MIT"++ },++ "node_modules/jsonwebtoken": {++ "version": "9.0.3",++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",++ "license": "MIT",++ "dependencies": {++ "jws": "^4.0.1",++ "lodash.includes": "^4.3.0",++ "lodash.isboolean": "^3.0.3",++ "lodash.isinteger": "^4.0.4",++ "lodash.isnumber": "^3.0.3",++ "lodash.isplainobject": "^4.0.6",++ "lodash.isstring": "^4.0.1",++ "lodash.once": "^4.0.0",++ "ms": "^2.1.1",++ "semver": "^7.5.4"++ },++ "engines": {++ "node": ">=12",++ "npm": ">=6"++ }++ },++ "node_modules/jsonwebtoken/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/jwa": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",++ "license": "MIT",++ "dependencies": {++ "buffer-equal-constant-time": "^1.0.1",++ "ecdsa-sig-formatter": "1.0.11",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/jws": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",++ "license": "MIT",++ "dependencies": {++ "jwa": "^2.0.1",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/lodash.defaults": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",++ "license": "MIT"++ },++ "node_modules/lodash.includes": {++ "version": "4.3.0",++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",++ "license": "MIT"++ },++ "node_modules/lodash.isarguments": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",++ "license": "MIT"++ },++ "node_modules/lodash.isboolean": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",++ "license": "MIT"++ },++ "node_modules/lodash.isinteger": {++ "version": "4.0.4",++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",++ "license": "MIT"++ },++ "node_modules/lodash.isnumber": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",++ "license": "MIT"++ },++ "node_modules/lodash.isplainobject": {++ "version": "4.0.6",++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",++ "license": "MIT"++ },++ "node_modules/lodash.isstring": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",++ "license": "MIT"++ },++ "node_modules/lodash.once": {++ "version": "4.1.1",++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",++ "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": "0.3.0",++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/merge-descriptors": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/methods": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime": {++ "version": "1.6.0",++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",++ "license": "MIT",++ "bin": {++ "mime": "cli.js"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/mime-db": {++ "version": "1.52.0",++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime-types": {++ "version": "2.1.35",++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",++ "license": "MIT",++ "dependencies": {++ "mime-db": "1.52.0"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "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.3",++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",++ "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.2",++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",++ "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",++ "license": "MIT",++ "dependencies": {++ "ee-first": "1.1.1"++ },++ "engines": {++ "node": ">= 0.8"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/openvibe-contracts": {++ "version": "0.265.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.265.0",++ "integrity": "sha512-0KnKj1Rig6acZcv4U2uwaM2msarGUY36pNqFbqmKdLeOPPozf7haEDTam27A/WzSU9ii2yEZ6SnuusPPQoGZaA==",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1"++ },++ "bin": {++ "openvibe-contracts-check": "bin/check-service.js"++ },++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/openvibe-sdk": {++ "version": "0.70.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",++ "integrity": "sha512-iMNxlnRm0fmyj60nHiC/lKAaG3JAqiJgu42lShnkby4yF4dQJRG3G0trrqOoULZKBalrYHb/gjauRwNQYN/tog==",++ "license": "MIT",++ "bin": {++ "openvibe-download": "bin/openvibe-download.js"++ },++ "engines": {++ "node": ">=20"++ },++ "peerDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0"++ },++ "peerDependenciesMeta": {++ "@electric-sql/pglite": {++ "optional": true++ },++ "iovalkey": {++ "optional": true++ },++ "pg": {++ "optional": true++ }++ }++ },++ "node_modules/openvibe-shared": {++ "version": "4.31.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",++ "integrity": "sha512-Zk1t1RGsiszXJEyW3x/cVCYC6U1qyLXID43l/Ehv2Py5gGglz5CfgYlPcDGI7n3QACsCWoOIGZuMBvlyfxyXEA==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ },++ "bin": {++ "openvibe-build-assets": "scripts/build-assets.js"++ }++ },++ "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": "0.1.13",++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",++ "license": "MIT"++ },++ "node_modules/pg": {++ "version": "8.23.1",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",++ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.1",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.1",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.1"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.1",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",++ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.1",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",++ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",++ "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.16.1",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",++ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",++ "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.8",++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",++ "license": "MIT",++ "dependencies": {++ "forwarded": "0.2.0",++ "ipaddr.js": "1.9.1"++ },++ "engines": {++ "node": ">= 0.10"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/qs": {++ "version": "6.16.2",++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",++ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",++ "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.2.1",++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/raw-body": {++ "version": "2.5.3",++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "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/semver": {++ "version": "7.8.5",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",++ "license": "ISC",++ "bin": {++ "semver": "bin/semver.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/send": {++ "version": "0.19.2",++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "1.2.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.1",++ "mime": "1.6.0",++ "ms": "2.1.3",++ "on-finished": "~2.4.1",++ "range-parser": "~1.2.1",++ "statuses": "~2.0.2"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "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": "1.16.3",++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",++ "license": "MIT",++ "dependencies": {++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "parseurl": "~1.3.3",++ "send": "~0.19.1"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "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/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },++ "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": "1.6.18",++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",++ "license": "MIT",++ "dependencies": {++ "media-typer": "0.3.0",++ "mime-types": "~2.1.24"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "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/utils-merge": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4.0"++ }++ },++ "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/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"++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,37 @@++{++ "name": "openvibe-shoes",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe.Shoes — every shoe, every market, the right price.",++ "main": "server/index.js",++ "scripts": {++ "start": "node server/index.js",++ "dev": "NODE_ENV=development node server/index.js",++ "seed": "node scripts/seed.js",++ "test": "node test/run.js",++ "test:pg": "SHOES_TEST_STORE=pg node test/run.js"++ },++ "keywords": [++ "openvibe"++ ],++ "author": "OpenVibers",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "express-rate-limit": "^7.5.0",++ "helmet": "^8.1.0",++ "iovalkey": "^0.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.265.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",++ "pg": "^8.23.1"++ },++ "engines": {++ "node": ">=22.0.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"++ }++}nnew file mode 100644@@@ -0,0 +1,86 @@++/* The page's own type and the skip link (the room template carried neither). */++.sh { font-family: var(--ovk-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif); }++.skip { position: absolute; left: -9999px; }++.skip:focus { left: 12px; top: 12px; z-index: 99; padding: 8px 12px; border-radius: 10px; background: var(--bg-card, #0f1729); color: inherit; }++++/* OpenVibe.Shoes — the small set of page pieces the kit does not draw (tables, forms, the price chart).++ The room's look and every card, rail and scene come from kit.css, room.css and showcase.css. */++.sh-wrap { width: min(1180px, 100% - 32px); margin: 0 auto; }++.sh-page { padding: 28px 0 64px; }++.sh-page-h { max-width: 780px; margin: 0 0 18px; }++.sh-page-h h1 { margin: 0 0 8px; font-size: clamp(26px, 4vw, 40px); letter-spacing: -.02em; }++.sh-kicker { margin: 0 0 6px; font-size: 12px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; color: var(--room-accent, var(--accent)); }++.sh-lede { margin: 0; color: var(--text-secondary, #96a7c2); line-height: 1.6; font-size: 16.5px; }++.sh-muted { color: var(--text-muted, #7386a3); }++.sh-fine { margin: 10px 0 0; color: var(--text-muted, #7386a3); font-size: 13px; line-height: 1.55; }++.sh-good { color: var(--ok, #34d399); font-weight: 700; }++.sh-stale { color: var(--warn, #fbbf24); font-weight: 700; }++.sh-fresh { color: var(--ok, #34d399); font-weight: 700; }++.sh-sec { margin: 34px 0 0; }++.sh-sec h2 { margin: 0 0 8px; font-size: clamp(20px, 2.4vw, 26px); }++.sh-sec h3 { margin: 0 0 4px; font-size: 15px; }++.sh-btn { display: inline-flex; align-items: center; gap: 7px; min-height: 40px; padding: 8px 14px; border: 1px solid var(--room-accent, var(--accent)); border-radius: 10px; background: var(--room-accent, var(--accent)); color: #fff; font: inherit; font-weight: 700; text-decoration: none; cursor: pointer; }++.sh-btn svg { width: 18px; height: 18px; }++.sh-btn-quiet { background: transparent; color: var(--room-strong, var(--text-primary)); }++.sh-btn:hover { filter: brightness(1.06); }++.sh-search { display: grid; grid-template-columns: minmax(200px, 2fr) repeat(5, minmax(110px, 1fr)) auto; gap: 10px; align-items: end; padding: 14px; margin: 0 0 20px; border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 14px; background: var(--bg-secondary, #101828); }++.sh-search label { display: grid; gap: 5px; font-size: 12.5px; font-weight: 700; color: var(--text-secondary, #96a7c2); }++.sh-search input, .sh-search select { width: 100%; min-height: 40px; padding: 8px 10px; border: 1px solid var(--border, rgba(255,255,255,.14)); border-radius: 9px; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: inherit; }++.sh-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 14px; margin: 0; padding: 0; list-style: none; }++.sh-grid > li { min-width: 0; }++.sh-empty { padding: 28px; margin: 20px 0; border: 1px dashed var(--border, rgba(255,255,255,.16)); border-radius: 16px; }++.sh-notice { padding: 12px 14px; margin: 14px 0; border: 1px solid var(--room-accent, var(--accent)); border-radius: 12px; background: color-mix(in srgb, var(--room-accent, var(--accent)) 12%, transparent); }++.sh-table-wrap { overflow-x: auto; margin: 10px 0; }++.sh-table { width: 100%; border-collapse: collapse; font-size: 14px; }++.sh-table caption { text-align: left; padding: 0 0 8px; color: var(--text-muted, #7386a3); font-size: 13px; }++.sh-table th, .sh-table td { padding: 9px 10px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); text-align: left; vertical-align: top; }++.sh-table th { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.sh-table small { color: var(--text-muted, #7386a3); }++.sh-watch { margin: 20px 0; padding: 18px; border: 1px solid var(--room-accent, var(--accent)); border-radius: 16px; background: var(--bg-secondary, #101828); }++.sh-watch h2 { margin: 0 0 6px; font-size: 20px; }++.sh-form-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: end; }++.sh-form-row label { display: grid; gap: 5px; font-size: 12.5px; font-weight: 700; color: var(--text-secondary, #96a7c2); }++.sh-form-row input, .sh-form-row select { min-height: 40px; padding: 8px 10px; border: 1px solid var(--border, rgba(255,255,255,.14)); border-radius: 9px; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: inherit; }++.sh-shoe-h { margin: 14px 0 18px; }++.sh-shoe-h h1 { margin: 0 0 8px; font-size: clamp(28px, 5vw, 46px); letter-spacing: -.02em; }++.sh-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; padding: 0; list-style: none; }++.sh-chips li { padding: 5px 10px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.14)); font-size: 13px; }++.sh-chart { margin: 10px 0; padding: 12px; border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 14px; background: var(--bg-secondary, #101828); }++.sh-chart svg { display: block; width: 100%; height: auto; }++.sh-chart-axis { stroke: var(--border, rgba(255,255,255,.16)); stroke-width: 1; }++.sh-chart-line { stroke: var(--room-accent, var(--accent)); stroke-width: 2.5; stroke-linejoin: round; }++.sh-chart circle { fill: var(--room-accent, var(--accent)); }++.sh-chart figcaption { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: baseline; margin-top: 6px; font-size: 13px; color: var(--text-muted, #7386a3); }++.sh-chart figcaption b { font-size: 18px; color: var(--text-primary, #e6edf7); }++.sh-curve { display: flex; gap: 8px; align-items: end; overflow-x: auto; padding: 10px 2px; }++.sh-curve-point { display: grid; justify-items: center; gap: 3px; min-width: 54px; font-size: 12px; }++.sh-curve-size { font-weight: 800; }++.sh-curve-bar { display: block; width: 26px; height: 110px; border-radius: 6px 6px 0 0; background: linear-gradient(to top, color-mix(in srgb, var(--room-accent, var(--accent)) 30%, transparent), var(--room-accent, var(--accent))); clip-path: inset(calc(100% - var(--h)) 0 0 0); }++.sh-curve-point small { color: var(--text-muted, #7386a3); }++.sh-facts { display: grid; gap: 14px; margin: 34px 0 0; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); }++.sh-facts > h2, .sh-facts > p { grid-column: 1 / -1; }++.sh-fact { padding: 14px; border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 14px; background: var(--bg-secondary, #101828); }++.sh-fact dl { margin: 0; } .sh-fact dt { font-weight: 700; margin-top: 8px; } .sh-fact dd { margin: 2px 0 0; color: var(--text-secondary, #96a7c2); }++.sh-drops { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }++.sh-drop { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; padding: 14px; border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 14px; background: var(--bg-secondary, #101828); }++.sh-drop > div { display: grid; gap: 3px; min-width: 240px; }++.sh-drop-meta, .sh-drop-src { color: var(--text-muted, #7386a3); font-size: 13px; }++.sh-colour { color: var(--text-secondary, #96a7c2); }++.sh-drop-day h2 { margin: 26px 0 8px; font-size: 20px; }++.sh-scene-note, .sh-scene-drop, .sh-scene-track { padding: 16px; border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 14px; background: var(--bg-secondary, #101828); }++.sh-scene-drop { display: grid; gap: 5px; }++.sh-scene-drop b { font-size: 20px; }++.sh-scene-tag { justify-self: start; padding: 3px 9px; border-radius: 999px; background: color-mix(in srgb, var(--room-accent, var(--accent)) 18%, transparent); color: var(--room-strong, var(--text-primary)); font-size: 12px; font-weight: 800; }++.sh-scene-track { display: grid; gap: 8px; }++.sh-scene-track > div { display: flex; flex-wrap: wrap; gap: 8px; }++.sh-scene-track input, .sh-track-form input { flex: 1 1 260px; min-height: 40px; padding: 8px 10px; border: 1px solid var(--border, rgba(255,255,255,.14)); border-radius: 9px; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: inherit; }++.sh-track-form { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; padding: 16px; border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 14px; background: var(--bg-secondary, #101828); }++.sh-track-form label { flex-basis: 100%; font-weight: 700; }++.sh-list { padding-left: 20px; line-height: 1.65; color: var(--text-secondary, #96a7c2); }++.sh-code { overflow-x: auto; padding: 12px 14px; border-radius: 12px; background: var(--bg-secondary, #101828); border: 1px solid var(--border, rgba(255,255,255,.1)); }++.sh-page-nav { display: flex; gap: 14px; align-items: center; justify-content: center; margin: 24px 0; }++.sh-page-nav .is-off { opacity: .4; pointer-events: none; }++.crumbs { font-size: 13px; color: var(--text-muted, #7386a3); margin: 10px 0 0; }++.crumbs a { color: inherit; }++@media (max-width: 900px) { .sh-search { grid-template-columns: 1fr 1fr; } }++@media (max-width: 640px) { .sh-search { grid-template-columns: 1fr; } .sh-wrap { width: min(100% - 24px, 1180px); } }nnew file mode 100644@@@ -0,0 +1,69 @@++#!/usr/bin/env node++'use strict';++++/**++ * One first run of every source, locally, and a printed report of what was written.++ *++ * node scripts/seed.js # offers + drops + ebay (if configured)++ * SHOES_OFFERS_PAGES=80 node scripts/seed.js++ *++ * It reads the live public sources (OpenVibe.Deals, the official release page, eBay only with keys). It writes++ * the same database the service uses: PostgreSQL when DATABASE_URL is set, else the development PGlite directory.++ * Nothing is invented: a source that does not answer is reported and the other jobs still run.++ */++const configLib = require('../server/config');++const { openStore } = require('../server/db');++const { createCatalogue } = require('../server/catalogue');++const { createUpstreams } = require('../server/upstreams');++const { createOffersJob } = require('../server/jobs/offers');++const { createDropsJob } = require('../server/jobs/drops');++const { createEbayJob } = require('../server/jobs/ebay');++++async function main() {++ const config = configLib.load();++ const log = console;++ const s = await openStore(config, { log });++ const catalogue = createCatalogue(s, { staleHours: config.sources.staleHours });++ const upstreams = createUpstreams({ config, log });++ const offers = createOffersJob({ config, catalogue, upstreams, log });++ const drops = createDropsJob({ config, catalogue, upstreams, log });++ const ebay = createEbayJob({ config, catalogue, upstreams, log });++ const report = { started_at: new Date().toISOString(), sources: {}, counts: {} };++++ try {++ const steps = [++ ['offers', () => offers.runOnce({ pages: config.sources.offersPages, maxProductLookups: config.sources.productLookups })],++ ['drops', () => drops.runOnce()],++ ['ebay', () => ebay.runOnce()],++ ];++ for (const [name, run] of steps) {++ const at = Date.now();++ try {++ const out = await run();++ report.sources[name] = { ok: true, ms: Date.now() - at, ...out };++ console.log(`[seed] ${name}: ok in ${Date.now() - at} ms`, out);++ } catch (err) {++ report.sources[name] = { ok: false, ms: Date.now() - at, error: err && err.message ? err.message : String(err) };++ console.warn(`[seed] ${name}: failed — ${err && err.message ? err.message : err}`);++ }++ }++ report.counts = await catalogue.counts();++ report.jobs = await catalogue.lastJobRuns();++ report.finished_at = new Date().toISOString();++ console.log('\n[seed] counts:', report.counts);++ console.log('[seed] jobs:', report.jobs.map((j) => `${j.job}:${j.ok ? 'ok' : 'failed'}`).join(' '));++ console.log(`[seed] ${JSON.stringify(report)}`);++ } finally {++ await s.close();++ }++ return report;++}++++if (require.main === module) {++ main().then((report) => {++ const any = Object.values(report.sources).some((x) => x.ok);++ process.exit(any ? 0 : 1);++ }, (err) => { console.error('[seed] fatal:', err); process.exit(1); });++}++++module.exports = { main };nnew file mode 100644@@@ -0,0 +1,180 @@++'use strict';++++/**++ * OpenVibe.Shoes — Express app factory; server/index.js listens, tests build their own instance with a temp++ * database and a mock Network.++ *++ * /, /shoes, /shoe/:slug, /drops, /track, /watches, /developers the pages (http/pages.js)++ * /api/v1/* the API (http/api.js)++ * /api/openapi.json the OpenAPI document++ * /auth/* Network SSO with PKCE (auth/sso.js)++ * /api/health, /api/ready, /release.json, /metrics (loopback only)++ * /internal/events account export/deletion (loopback only)++ *++ * The offers, drops and eBay jobs are made here but started by server/index.js (and never under SHOES_DRILL);++ * tests drive their runOnce().++ */++const path = require('path');++const express = require('express');++const helmet = require('helmet');++const cookieParser = require('cookie-parser');++const rateLimit = require('express-rate-limit');++const contracts = require('openvibe-contracts');++const cache = require('openvibe-shared/cache-policy');++++const configLib = require('./config');++const { openStore } = require('./db');++const { createKeyStore } = require('./auth/keys');++const { createSso } = require('./auth/sso');++const { createPrincipal } = require('./http/principal');++const { createApi } = require('./http/api');++const { createPageRoutes } = require('./http/pages');++const { openapi } = require('./http/openapi');++const { createServiceReadiness } = require('./observability');++const { assetVersion, setRelease } = require('./render/layout');++const { createCatalogue } = require('./catalogue');++const { createUpstreams } = require('./upstreams');++const { createJobs } = require('./jobs');++++const accountDataLib = require('./identity/account-data');++const { createNetworkSender } = require('openvibe-sdk/account-data');++++const PUBLIC_DIR = path.join(__dirname, '..', 'public');++const VERSION = require('../package.json').version;++const { createHttpTelemetry } = require('openvibe-sdk/service');++const { createPgTelemetrySink } = require('openvibe-sdk/telemetry');++++/**++ * opts: config, store, now (clock), fetchImpl (Network and the public upstreams), log, valkey, accountSend (a++ * stand-in for Network's internal routes; tests only), telemetrySink.++ */++async function createApp(opts = {}) {++ const config = opts.config || configLib.load();++ const log = opts.log || console;++ const fetchImpl = opts.fetchImpl || globalThis.fetch;++ const s = opts.store || await openStore(config, { now: opts.now, log });++++ const keys = createKeyStore({ config, fetchImpl, log });++ const sso = createSso({ config, keys, fetchImpl, now: s.now, log });++ const principal = createPrincipal({ config, keys });++ const catalogue = createCatalogue(s, { staleHours: config.sources.staleHours });++ const upstreams = createUpstreams({ config, fetchImpl, log, urlCheck: opts.urlCheck });++ const jobs = createJobs({ config, catalogue, upstreams, log });++++ // Account export and deletion (ADR-033). The sender posts to Network's internal routes with this service's own++ // client-credentials token; a test injects a stand-in through opts.accountSend.++ const accountData = accountDataLib.create({ db: s.db, log });++ const accountSend = opts.accountSend || (config.oauth.clientSecret++ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })++ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Shoes cannot answer account events'); });++++ const ctx = { config, s, keys, sso, principal, catalogue, upstreams, jobs, accountData, accountSend, log };++++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', config.trustProxy);++ const release = require('openvibe-shared/release').createRelease({ service: 'shoes', root: path.join(__dirname, '..') });++ setRelease(release.release);++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'shoes', release: release.release });++ app.locals.metrics = metrics.registry;++ ctx.telemetry = createHttpTelemetry({ service: 'shoes', sink: opts.telemetrySink || createPgTelemetrySink({ db: s.db, log }), skipPrefixes: ['/shared'], intervalMs: 60_000 });++ app.use(ctx.telemetry.middleware());++ app.locals.ctx = ctx;++++ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);++ ctx.valkey = valkey;++++ app.use(contracts.http.middleware());++ app.use(helmet({++ contentSecurityPolicy: {++ directives: {++ defaultSrc: ["'self'"],++ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],++ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],++ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],++ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media', 'https://static.nike.com'],++ connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],++ frameSrc: ["'self'", 'https://openvibe.network'],++ frameAncestors: ["'none'"],++ objectSrc: ["'none'"],++ baseUri: ["'self'"],++ formAction: ["'self'", 'https://openvibe.network'],++ },++ },++ frameguard: { action: 'deny' },++ crossOriginEmbedderPolicy: false,++ crossOriginResourcePolicy: { policy: 'same-site' },++ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },++ }));++ app.use(cookieParser());++++ // ── Machine endpoints ───────────────────────────────────++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-shoes', name: 'OpenVibe.Shoes', version: VERSION }));++ release.mount(app, { registry: metrics.registry });++ const readiness = createServiceReadiness({ s, config, release: release.release, valkey });++ app.get('/api/ready', readiness.handler);++++ // ── Who is asking (verified offline; refreshed when expired) ──++ app.use(sso.middleware());++++ // ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──++ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));++ app.use('/auth', sso.routes());++ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'shoes', service: 'shoes', host: 'open.shoes', name: 'OpenVibe.Shoes', profile: 'ugc' })); }++++ // ── OpenVibe.Events → this service (loopback only) ──────++ // Account export/deletion; the consumer reads the raw body itself, so it is mounted before any body parser.++ // nginx answers 404 for /internal/.++ app.use('/internal/events', accountData.consumer({++ secrets: config.events.secrets,++ send: accountSend,++ now: s.now,++ onEvent: async (ev) => `ignored:${ev.event_type}`,++ }));++++ // ── Static assets (content-hashed ?v= → immutable) ──────++ app.use('/shared', require('openvibe-shared/serve').handler());++ app.use(express.static(PUBLIC_DIR, {++ index: false, redirect: false,++ setHeaders(res, filePath) {++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');++ const v = res.req && res.req.query && res.req.query.v;++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));++ },++ }));++++ // ── API ─────────────────────────────────────────────────++ app.get('/api/openapi.json', (req, res) => res.json(openapi(config)));++ app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.SHOES_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));++ app.use('/api/v1', createApi(ctx));++ app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));++++ // ── Pages ───────────────────────────────────────────────++ app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.SHOES_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));++ app.use(createPageRoutes(ctx));++ app.use((req, res) => sendNotFound(req, res, ctx));++++ // eslint-disable-next-line no-unused-vars++ app.use((err, req, res, _next) => {++ log.error('[OpenVibe.Shoes]', err && err.message ? err.message.slice(0, 300) : err);++ if (res.headersSent) return;++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (err && err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });++ if (err && err.type === 'entity.too.large') return req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov }) : res.status(413).type('text/plain').send('That request was too large.');++ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });++ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');++ });++++ return { app, ctx };++}++++function sendNotFound(req, res, ctx) {++ const views = require('./views');++ const { send } = require('./render/layout');++ return send(res, 404, {++ viewer: req.viewer, config: ctx.config, path: req.originalUrl, title: 'Not found',++ body: views.notFound(), active: '/',++ });++}++++module.exports = { createApp };nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++++/**++ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured internal URL and++ * kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline against it: the person's session token++ * (sign-in) and a person's Bearer token (the API).++ *++ * keys.get() / keys.ensure() / keys.loaded() for readiness and the SSO middleware, and verification through the++ * SDK's verifyUserToken / verifyAppToken with `jwks: <url>`. Nothing the SDK says about a failed key fetch leaves++ * this module: token.no_key answers 'signing key not loaded yet'.++ */++const sdk = require('openvibe-sdk/auth');++++const KEY_UNAVAILABLE = 'signing key not loaded yet';++const DOES_NOT_VERIFY = 'token does not verify';++++function publicReason(err) {++ const code = err && err.code;++ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;++ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;++}++++function jwksUrl(config) {++ return (config.networkInternalUrl || config.networkUrl) + '/api/.well-known/jwks';++}++++function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {++ const url = jwksUrl(config);++ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });++ let mirror = null;++++ async function refreshMirror() {++ try {++ const ks = await client.keys();++ const first = ks && ks[0];++ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;++ } catch (err) { log.warn(`[OpenVibe.Shoes] JWKS mirror refresh failed: ${err && err.message || err}`); mirror = null; }++ }++ async function ensure() { await refreshMirror(); }++ function get() { return mirror ? mirror.pem : null; }++ function loaded() { return Boolean(mirror); }++++ const asMs = (v) => (typeof v === 'function' ? v() : (typeof v === 'number' ? v : Date.now()));++++ async function verifyUser(token, { issuer, audience, now = Date.now() } = {}) {++ try {++ const claims = await sdk.verifyUserToken(token, { jwks: url, issuer, audience, now: asMs(now), log });++ await refreshMirror();++ return { ok: true, claims };++ } catch (err) {++ await refreshMirror();++ const code = err && err.code;++ log.warn(`[OpenVibe.Shoes] user token rejected (${code || 'error'}):`, (err && err.message) || err);++ return { ok: false, reason: publicReason(err), code, expired: code === 'token.expired' };++ }++ }++++ async function verifyApp(token, { issuer, audience, acceptSandbox = true, now = Date.now() } = {}) {++ try {++ const claims = await sdk.verifyAppToken(token, { jwks: url, issuer, audience, acceptSandbox, now: asMs(now), log });++ await refreshMirror();++ return { ok: true, claims };++ } catch (err) {++ await refreshMirror();++ const code = err && err.code;++ log.warn(`[OpenVibe.Shoes] app token rejected (${code || 'error'}):`, (err && err.message) || err);++ return { ok: false, reason: publicReason(err), code };++ }++ }++++ async function pemForToken(token) {++ let kid = null;++ try { kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { /* the guard refuses it */ }++ try {++ const ks = await client.keysForKid(kid);++ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];++ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;++ } catch { return null; }++ }++++ return { ensure, get, loaded, status: client.status, verifyUser, verifyApp, pemForToken, client };++}++++module.exports = { createKeyStore, jwksUrl };nnew file mode 100644@@@ -0,0 +1,260 @@++'use strict';++++/**++ * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `shoes`.++ *++ * GET /auth/login → Network /oauth/authorize?…&code_challenge=…&code_challenge_method=S256++ * (?silent=1 adds prompt=none; ?next= a same-site path)++ * GET /auth/callback → state check, server-side code exchange (client secret + code_verifier)++ * GET /auth/logout → clear cookies, revoke the refresh token (best effort)++ * GET /auth/me → { user } for the shared navbar; { user: null } for a guest++ *++ * This is Today's SSO module with the client id and cookie names changed. The person's Network access token lives++ * in an httpOnly cookie (shoes_at) no script can read; the refresh token (shoes_rt) is httpOnly too. Neither is ever++ * stored server-side, rendered or logged.++ */++const crypto = require('crypto');++const express = require('express');++const cache = require('openvibe-shared/cache-policy');++++const ACCESS_COOKIE = 'shoes_at';++const REFRESH_COOKIE = 'shoes_rt';++const HINT_COOKIE = 'ov_sso_hint';++const FLOW_COOKIE = 'shoes_oauth';++++function pkcePair() {++ const verifier = crypto.randomBytes(32).toString('base64url');++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');++ return { verifier, challenge };++}++++function ownOrigins(config) {++ const out = new Set();++ for (const u of [config && config.baseUrl, config && config.homes && config.homes.url]) {++ try { if (u) out.add(new URL(u).origin); } catch { /* not a URL */ }++ }++ return out;++}++++/** Same-site relative paths only (never protocol-relative, never another origin). */++function sanitizeNext(next, config) {++ if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';++ if (typeof next === 'string' && /^https?:\/\//i.test(next)) {++ let u = null;++ try { u = new URL(next); } catch { return '/'; }++ if (!ownOrigins(config).has(u.origin)) return '/';++ next = `${u.pathname}${u.search}${u.hash}`;++ }++ if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';++ return next;++}++++function authorizeUrl(config, { state, challenge, silent = false }) {++ const q = new URLSearchParams({++ response_type: 'code',++ client_id: config.oauth.clientId,++ redirect_uri: config.oauth.redirectUri,++ scope: config.oauth.scope,++ state,++ code_challenge: challenge,++ code_challenge_method: 'S256',++ });++ if (silent) q.set('prompt', 'none');++ return `${config.networkUrl}/oauth/authorize?${q.toString()}`;++}++++function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {++ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });++++ function setSession(res, data) {++ res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });++ if (data.refresh_token) res.cookie(REFRESH_COOKIE, data.refresh_token, { ...cookieBase(), maxAge: 30 * 24 * 60 * 60 * 1000 });++ res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });++ }++ function clearSession(res) {++ res.clearCookie(ACCESS_COOKIE, cookieBase());++ res.clearCookie(REFRESH_COOKIE, cookieBase());++ }++++ async function tokenGrant(fields) {++ const body = new URLSearchParams({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...fields });++ let res;++ try {++ res = await fetchImpl(`${config.networkInternalUrl}/oauth/token`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },++ body, signal: AbortSignal.timeout(10_000),++ });++ } catch (err) {++ throw Object.assign(new Error('OpenVibe.Network did not answer'), { status: 502, error: err && err.name === 'TimeoutError' ? 'timeout' : 'unreachable' });++ }++ const data = await res.json().catch(() => null);++ if (!res.ok || !data || !data.access_token) {++ throw Object.assign(new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`),++ { status: res.status, error: (data && data.error) || 'invalid_grant' });++ }++ return data;++ }++++ /**++ * A Shoes session is a Network SESSION token: audience openvibe.network and a person. FedCM assertions,++ * app/service tokens and internal tokens are signed with the same key and issuer; none may become a session here.++ */++ async function verifySession(token) {++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });++ const c = v.claims;++ if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };++ return v;++ }++++ function viewerFromClaims(claims, token) {++ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;++ const role = typeof claims.role === 'string' ? claims.role : 'user';++ return {++ kind: 'user',++ subject,++ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,++ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (typeof claims.username === 'string' ? claims.username.slice(0, 64) : 'you'),++ role,++ avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,++ profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,++ token,++ };++ }++++ async function refresh(req, res) {++ if (!req.sessionRefresh) req.sessionRefresh = await doRefresh(req, res);++ return req.sessionRefresh;++ }++++ async function doRefresh(req, res) {++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];++ if (!rt) return null;++ try {++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });++ setSession(res, data);++ const v = await verifySession(data.access_token);++ return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;++ } catch (err) {++ if (err.status && err.status < 500) clearSession(res);++ return null;++ }++ }++++ /** req.viewer for every page: the signed-in person, refreshed when their token expired. */++ function middleware() {++ return async (req, res, next) => {++ req.viewer = { kind: 'anonymous' };++ const token = req.cookies && req.cookies[ACCESS_COOKIE];++ if (!token) return next();++ await keys.ensure();++ const v = await verifySession(token);++ if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }++ if (v.expired) {++ const fresh = await refresh(req, res);++ if (fresh) { req.viewer = fresh; return next(); }++ }++ if (keys.loaded()) clearSession(res);++ next();++ };++ }++++ async function asViewer(req, res, fn) {++ try {++ return await fn(req.viewer.token);++ } catch (err) {++ if (!err || err.status !== 401) throw err;++ const fresh = await refresh(req, res);++ if (!fresh) throw err;++ req.viewer = fresh;++ return fn(fresh.token);++ }++ }++++ function routes() {++ const r = express.Router();++ const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });++++ r.get('/login', (req, res) => {++ const silent = Boolean(req.query.silent) && req.query.silent !== '0';++ const state = crypto.randomBytes(16).toString('hex');++ const { verifier, challenge } = pkcePair();++ res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next, config), silent }), flowOpts());++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ res.redirect(authorizeUrl(config, { state, challenge, silent }));++ });++++ r.get('/callback', async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ let flow = null;++ try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }++ res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });++ const next = flow ? sanitizeNext(flow.next, config) : '/';++ const { code, state, error } = req.query;++ if (error) {++ if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);++ return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);++ }++ if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'++ || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {++ return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');++ }++ if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');++ try {++ const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: config.oauth.redirectUri, code_verifier: flow.verifier });++ setSession(res, data);++ res.set('Clear-Site-Data', '"cache"');++ return res.redirect(next);++ } catch (err) {++ log.warn('[OpenVibe.Shoes] sign-in exchange failed:', err.status || '', err.error || '');++ return res.status(err.status && err.status < 500 ? 400 : 502).type('text/plain')++ .send(err.status && err.status < 500 ? 'The Network refused the sign-in code. Please sign in again.' : 'Sign-in failed: OpenVibe.Network did not answer. Please try again.');++ }++ });++++ const sameOriginRequest = (req) => {++ const site = String(req.get('sec-fetch-site') || '');++ if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');++ const origin = req.get('origin');++ if (origin) return origin === `${req.protocol}://${req.get('host')}`;++ return req.method === 'GET';++ };++ r.get('/logout', (req, res, next) => {++ if (sameOriginRequest(req)) return next();++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next, config))}` : '';++ res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`++ + `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`++ + `<p>Sign out of OpenVibe.Shoes?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);++ });++ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));++ r.all('/logout', async (req, res) => {++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];++ if (rt) {++ fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {++ method: 'POST', headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: rt }),++ signal: AbortSignal.timeout(3000),++ }).catch(() => {});++ }++ clearSession(res);++ res.set('Clear-Site-Data', '"cache"');++ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });++ res.redirect(303, sanitizeNext(req.query.next, config));++ });++++ r.get('/me', (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ const c = req.cookies || {};++ if (!c[ACCESS_COOKIE] && !c[REFRESH_COOKIE] && !req.get('authorization')) return res.json({ user: null });++ const v = req.viewer;++ if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });++ res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role, avatar_url: v.avatarUrl || null, profile_color: v.profileColor || null } });++ });++ return r;++ }++++ return { middleware, routes, asViewer, tokenGrant, pkcePair };++}++++module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };nnew file mode 100644@@@ -0,0 +1,580 @@++'use strict';++++/**++ * Shoes' catalogue: the models, the offers on them, the price history, the size-price curve, the drops, the cited++ * facts and a person's watches. Everything the pages and the API show is read here; nothing is computed in the view.++ *++ * The rules that matter:++ * - an offer keeps the time its source was checked (checked_at) and a stale flag (older than the hours allowed);++ * - the price history keeps ONE snapshot per offer per day: the first check of that day wins, so a busy store++ * cannot invent a curve by being checked often;++ * - the all-in price is the price plus the shipping when the store states shipping, and the wire says whether++ * the shipping was known;++ * - a "drop" is a real saving the source stated (its list price against its current all-in price) or the fall++ * between two of our own daily snapshots. It is never a made-up number;++ * - a model is a brand and a model name; the page is /shoe/<brand>-<model>.++ */++const crypto = require('crypto');++const parse = require('./domain/parse');++++const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));++const num = (v) => (v == null || v === '' ? null : (Number.isFinite(Number(v)) ? Number(v) : null));++const money = (v) => (v == null ? null : Math.round(Number(v) * 100) / 100);++++/** The wire form of an offer: what the source said, and when. */++function wireOffer(row, { staleHours = 48, now = Date.now() } = {}) {++ if (!row) return null;++ const price = num(row.price_amount);++ const shipping = num(row.shipping_amount);++ const list = num(row.list_price_amount);++ const all = parse.allIn(price, shipping);++ const drop = list != null && all.amount != null ? money(list - all.amount) : null;++ return {++ id: row.id,++ model_id: row.model_id,++ slug: row.slug || null,++ store: row.store_domain,++ store_name: row.store_name || null,++ title: row.title,++ url: row.url,++ price,++ list_price: list,++ shipping,++ shipping_known: shipping != null,++ all_in: all.amount,++ drop_from_list: drop != null && drop > 0 ? drop : null,++ currency: row.currency || 'USD',++ condition: row.condition || null,++ size: row.size || null,++ colourway: row.colourway || null,++ checked_at: iso(row.checked_at),++ stale: row.stale === true || parse.isStale(row.checked_at, now, staleHours),++ source: row.source,++ };++}++++/** The wire form of a model with its numbers. */++function wireModel(row, { now = Date.now() } = {}) {++ if (!row) return null;++ const priceMin = num(row.price_min);++ const priceMax = num(row.price_max);++ const drop = num(row.drop_amount);++ return {++ id: row.id,++ slug: row.slug,++ brand: row.brand,++ model: row.model,++ name: row.name,++ price_min: priceMin,++ price_max: priceMax,++ offer_count: Number(row.offer_count) || 0,++ store_count: Number(row.store_count) || 0,++ stale_count: Number(row.stale_count) || 0,++ checked_at: iso(row.checked_at),++ drop_amount: drop != null && drop > 0 ? drop : null,++ drop_percent: row.drop_percent != null ? num(row.drop_percent) : (drop != null && row.price_max != null && Number(row.price_max) > 0 ? Math.round((drop / (Number(row.price_max) + drop)) * 10000) / 100 : null),++ first_seen_at: iso(row.first_seen_at),++ updated_at: iso(row.updated_at),++ };++}++++function wireDrop(row) {++ if (!row) return null;++ let sizes = row.sizes;++ if (typeof sizes === 'string') { try { sizes = JSON.parse(sizes); } catch { sizes = []; } }++ return {++ id: row.id,++ slug: row.slug,++ brand: row.brand,++ model: row.model,++ colourway: row.colourway || null,++ release_at: iso(row.release_at),++ price: num(row.price_amount),++ currency: row.currency || 'USD',++ sizes: Array.isArray(sizes) ? sizes.map(String) : [],++ source_name: row.source_name,++ source_url: row.source_url,++ fetched_at: iso(row.fetched_at),++ };++}++++function wireWatch(row) {++ if (!row) return null;++ return {++ id: row.id,++ kind: row.kind,++ slug: row.slug || row.model_slug || null,++ size: row.size || null,++ condition: row.condition || null,++ threshold: num(row.threshold_amount),++ currency: row.currency || 'USD',++ source_url: row.source_url || null,++ remote_id: row.remote_id || null,++ status: row.status,++ note: row.note || null,++ created_at: iso(row.created_at),++ };++}++++function shortHash(value) {++ return crypto.createHash('sha256').update(String(value || '')).digest('hex').slice(0, 6);++}++++function createCatalogue(s, { staleHours = 48 } = {}) {++ const db = s.db;++ const nowMs = () => s.now();++++ /** The model for a brand and a model name; it is created the first time it is seen. */++ async function ensureModel({ brand, model, name }) {++ const b = parse.cleanName(brand, 80);++ const m = parse.cleanName(model, 120);++ if (!b || !m) return null;++ const slug = parse.slugFor(b, m);++ if (!slug) return null;++ const existing = await db.maybe('SELECT * FROM shoe_models WHERE slug = $1', [slug]);++ if (existing) return existing;++ try {++ return await db.one(++ `INSERT INTO shoe_models (id, slug, brand, model, name, first_seen_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $6::timestamptz)++ ON CONFLICT (slug) DO UPDATE SET updated_at = EXCLUDED.updated_at++ RETURNING *`,++ [s.newId('shm'), slug, b, m, parse.cleanName(name || m, 120), s.iso()],++ );++ } catch (err) {++ // A concurrent importer inserted the same model between the SELECT and the INSERT.++ const again = await db.maybe('SELECT * FROM shoe_models WHERE slug = $1', [slug]);++ if (again) return again;++ throw err;++ }++ }++++ /**++ * One offer from a source. The model is created from the product name and the source's brand; the snapshot for++ * today is written only when there is none yet (one per offer per day).++ */++ async function upsertOffer(input) {++ const title = parse.cleanName(input.title, 200);++ if (!title) return null;++ const modelTitle = parse.cleanName(input.modelName || title, 200);++ const brand = parse.brandOf(modelTitle, input.brand);++ const model = parse.modelOf(modelTitle, brand);++ if (!brand || !model) return null;++ const modelRow = await ensureModel({ brand, model, name: modelTitle });++ if (!modelRow) return null;++++ const price = parse.amountOf(input.price);++ const listPrice = parse.amountOf(input.listPrice);++ const shipping = parse.amountOf(input.shipping);++ const checkedAt = input.checkedAt instanceof Date ? input.checkedAt : new Date(input.checkedAt || s.now());++ if (Number.isNaN(checkedAt.getTime())) return null;++ const condition = parse.conditionOf(input.condition);++ const size = parse.cleanSize(input.size || parse.sizeOf(title));++ const stale = parse.isStale(checkedAt, nowMs(), staleHours);++ const sourceId = String(input.sourceId || '').trim();++ if (!sourceId) return null;++ const url = String(input.url || '');++ if (!/^https?:\/\//i.test(url)) return null;++ const store = String(input.store || '').toLowerCase().slice(0, 120);++ if (!store) return null;++++ const row = await db.one(++ `INSERT INTO shoe_offers++ (id, model_id, source, source_id, store_domain, store_name, title, url, price_amount, list_price_amount,++ currency, shipping_amount, condition, size, colourway, checked_at, stale, active, raw, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::timestamptz, $17, true, $18::jsonb, $19::timestamptz, $19::timestamptz)++ ON CONFLICT (source, source_id) DO UPDATE SET++ model_id = EXCLUDED.model_id, store_name = EXCLUDED.store_name, title = EXCLUDED.title, url = EXCLUDED.url,++ price_amount = EXCLUDED.price_amount, list_price_amount = EXCLUDED.list_price_amount,++ currency = EXCLUDED.currency, shipping_amount = EXCLUDED.shipping_amount,++ condition = EXCLUDED.condition, size = EXCLUDED.size, colourway = EXCLUDED.colourway,++ checked_at = EXCLUDED.checked_at, stale = EXCLUDED.stale, active = true, raw = EXCLUDED.raw,++ updated_at = EXCLUDED.updated_at++ RETURNING *`,++ [++ s.newId('sho'), modelRow.id, String(input.source || 'deals'), sourceId, store,++ input.storeName || null, title, url, price, listPrice, String(input.currency || 'USD'), shipping,++ condition, size, parse.cleanName(input.colourway, 60) || null,++ checkedAt.toISOString(), stale, JSON.stringify(input.raw || {}), s.iso(),++ ],++ );++ if (!row) return null;++ await writeSnapshot(row, price, checkedAt);++ return row;++ }++++ /** One snapshot per offer per day: the first check of that day wins. */++ async function writeSnapshot(offer, price, checkedAt) {++ const day = parse.dayOf(checkedAt);++ if (!day) return null;++ return await db.maybe(++ `INSERT INTO shoe_snapshots (id, offer_id, model_id, price_amount, shipping_amount, checked_at, day, created_at)++ VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::date, $8::timestamptz)++ ON CONFLICT (offer_id, day) DO NOTHING++ RETURNING *`,++ [s.newId('shs'), offer.id, offer.model_id, price, num(offer.shipping_amount), checkedAt.toISOString(), day, s.iso()],++ );++ }++++ /** The models, with their numbers: the filters, the sort and the facets. */++ async function listShoes({ q = null, brand = null, size = null, condition = null, store = null, sort = 'recent', limit = 24, offset = 0 } = {}) {++ const args = [];++ const where = [];++ if (q) {++ args.push(`%${String(q).trim()}%`);++ where.push(`(m.name ILIKE $${args.length} OR m.brand ILIKE $${args.length} OR m.model ILIKE $${args.length})`);++ }++ if (brand) { args.push(String(brand)); where.push(`m.brand = $${args.length}`); }++ if (size) {++ args.push(String(size));++ where.push(`EXISTS (SELECT 1 FROM shoe_offers o WHERE o.model_id = m.id AND o.active AND o.size = $${args.length})`);++ }++ if (condition) {++ args.push(String(condition));++ where.push(`EXISTS (SELECT 1 FROM shoe_offers o WHERE o.model_id = m.id AND o.active AND o.condition = $${args.length})`);++ }++ if (store) {++ args.push(String(store));++ where.push(`EXISTS (SELECT 1 FROM shoe_offers o WHERE o.model_id = m.id AND o.active AND o.store_domain = $${args.length})`);++ }++ const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';++ const order = {++ price: 'price_min ASC NULLS LAST, m.name ASC',++ price_desc: 'price_max DESC NULLS LAST, m.name ASC',++ recent: 'checked_at DESC NULLS LAST, m.name ASC',++ drop: 'drop_amount DESC NULLS LAST, checked_at DESC NULLS LAST',++ popular: 'offer_count DESC NULLS LAST, checked_at DESC NULLS LAST',++ name: 'm.brand ASC, m.model ASC',++ }[sort] || 'checked_at DESC NULLS LAST, m.name ASC';++ const staleParam = args.length + 1;++ args.push(staleHours);++ const limitParam = args.length + 1;++ args.push(Math.max(1, Math.min(100, limit)));++ const offsetParam = args.length + 1;++ args.push(Math.max(0, offset));++ const rows = await db.many(++ `SELECT m.*,++ (SELECT MIN(o.price_amount + COALESCE(o.shipping_amount, 0)) FROM shoe_offers o WHERE o.model_id = m.id AND o.active AND o.price_amount IS NOT NULL) AS price_min,++ (SELECT MAX(o.price_amount + COALESCE(o.shipping_amount, 0)) FROM shoe_offers o WHERE o.model_id = m.id AND o.active AND o.price_amount IS NOT NULL) AS price_max,++ (SELECT COUNT(*)::int FROM shoe_offers o WHERE o.model_id = m.id AND o.active) AS offer_count,++ (SELECT COUNT(DISTINCT o.store_domain)::int FROM shoe_offers o WHERE o.model_id = m.id AND o.active) AS store_count,++ (SELECT COUNT(*) FILTER (WHERE o.stale OR o.checked_at < now() - ($${staleParam} || ' hours')::interval)::int FROM shoe_offers o WHERE o.model_id = m.id AND o.active) AS stale_count,++ (SELECT MAX(o.checked_at) FROM shoe_offers o WHERE o.model_id = m.id AND o.active) AS checked_at,++ (SELECT MAX(GREATEST(++ COALESCE(o.list_price_amount - (o.price_amount + COALESCE(o.shipping_amount, 0)), 0),++ COALESCE((++ SELECT x.prev_price - x.price_amount++ FROM (++ SELECT s.price_amount, LAG(s.price_amount) OVER (ORDER BY s.day) AS prev_price,++ ROW_NUMBER() OVER (ORDER BY s.day DESC) AS rn++ FROM shoe_snapshots s WHERE s.offer_id = o.id++ ) x WHERE x.rn = 1++ ), 0)++ )) FROM shoe_offers o WHERE o.model_id = m.id AND o.active AND o.price_amount IS NOT NULL) AS drop_amount++ FROM shoe_models m++ ${clause}++ ORDER BY ${order}++ LIMIT $${limitParam} OFFSET $${offsetParam}`,++ args,++ );++ return rows.map((r) => wireModel(r, { now: nowMs() }));++ }++++ /** How many models the same filters match, for a page's "next" link. */++ async function countShoes({ q = null, brand = null, size = null, condition = null, store = null } = {}) {++ const args = [];++ const where = [];++ if (q) {++ args.push(`%${String(q).trim()}%`);++ where.push(`(m.name ILIKE $${args.length} OR m.brand ILIKE $${args.length} OR m.model ILIKE $${args.length})`);++ }++ if (brand) { args.push(String(brand)); where.push(`m.brand = $${args.length}`); }++ if (size) { args.push(String(size)); where.push(`EXISTS (SELECT 1 FROM shoe_offers o WHERE o.model_id = m.id AND o.active AND o.size = $${args.length})`); }++ if (condition) { args.push(String(condition)); where.push(`EXISTS (SELECT 1 FROM shoe_offers o WHERE o.model_id = m.id AND o.active AND o.condition = $${args.length})`); }++ if (store) { args.push(String(store)); where.push(`EXISTS (SELECT 1 FROM shoe_offers o WHERE o.model_id = m.id AND o.active AND o.store_domain = $${args.length})`); }++ const row = await db.one(`SELECT COUNT(*)::int AS n FROM shoe_models m ${where.length ? `WHERE ${where.join(' AND ')}` : ''}`, args);++ return Number(row && row.n) || 0;++ }++++ /** The facets of the whole catalogue: the brands, the sizes, the conditions, the stores and the price range. */++ async function facets() {++ const [brands, sizes, conditions, stores, prices, checked, counts] = await Promise.all([++ db.many('SELECT m.brand AS value, COUNT(DISTINCT m.id)::int AS count FROM shoe_models m GROUP BY m.brand ORDER BY count DESC, value ASC'),++ db.many('SELECT o.size AS value, COUNT(*)::int AS count FROM shoe_offers o WHERE o.active AND o.size IS NOT NULL GROUP BY o.size ORDER BY count DESC, value ASC'),++ db.many('SELECT o.condition AS value, COUNT(*)::int AS count FROM shoe_offers o WHERE o.active AND o.condition IS NOT NULL GROUP BY o.condition ORDER BY count DESC, value ASC'),++ db.many('SELECT o.store_domain AS value, COUNT(*)::int AS count FROM shoe_offers o WHERE o.active GROUP BY o.store_domain ORDER BY count DESC, value ASC'),++ db.many('SELECT MIN(o.price_amount) AS min, MAX(o.price_amount) AS max FROM shoe_offers o WHERE o.active AND o.price_amount IS NOT NULL'),++ db.many('SELECT (SELECT MAX(x.checked_at) FROM shoe_offers x WHERE x.active) AS checked_at, COUNT(*) FILTER (WHERE o.stale OR o.checked_at < now() - ($1 || \' hours\')::interval)::int AS stale FROM shoe_offers o WHERE o.active', [staleHours]),++ db.many('SELECT (SELECT COUNT(*)::int FROM shoe_models) AS models, (SELECT COUNT(*)::int FROM shoe_offers WHERE active) AS offers, (SELECT COUNT(*)::int FROM shoe_drops WHERE release_at > now()) AS drops'),++ ]);++ const c = counts[0] || { models: 0, offers: 0, drops: 0 };++ return {++ brands: brands.map((b) => ({ value: b.value, count: Number(b.count) || 0 })),++ sizes: sizes.map((b) => ({ value: b.value, count: Number(b.count) || 0 })),++ conditions: conditions.map((b) => ({ value: b.value, count: Number(b.count) || 0 })),++ stores: stores.map((b) => ({ value: b.value, count: Number(b.count) || 0 })),++ price_min: num(prices[0] ? prices[0].min : null),++ price_max: num(prices[0] ? prices[0].max : null),++ checked_at: checked[0] ? iso(checked[0].checked_at) : null,++ stale_offers: Number(checked[0] ? checked[0].stale : 0) || 0,++ models: Number(c.models) || 0,++ offers: Number(c.offers) || 0,++ drops: Number(c.drops) || 0,++ };++ }++++ /** One model. */++ async function modelBySlug(slug) {++ return (await db.maybe('SELECT * FROM shoe_models WHERE slug = $1', [String(slug || '')])) || null;++ }++++ async function modelById(id) {++ if (!id) return null;++ return (await db.maybe('SELECT * FROM shoe_models WHERE id = $1', [String(id)])) || null;++ }++++ /** The offers of a model, cheapest all-in first. */++ async function offersOf(modelId) {++ const rows = await db.many(++ `SELECT o.*, m.slug FROM shoe_offers o JOIN shoe_models m ON m.id = o.model_id++ WHERE o.model_id = $1 AND o.active++ ORDER BY (o.price_amount + COALESCE(o.shipping_amount, 0)) ASC NULLS LAST, o.checked_at DESC`,++ [modelId],++ );++ return rows.map((r) => wireOffer(r, { staleHours, now: nowMs() }));++ }++++ /** The price history of a model: one point per day, the lowest all-in price of that day. */++ async function history(modelId, { days = 90 } = {}) {++ const rows = await db.many(++ `SELECT s.day, MIN(s.price_amount + COALESCE(s.shipping_amount, 0)) AS price, COUNT(*)::int AS offers, MAX(s.checked_at) AS checked_at++ FROM shoe_snapshots s++ WHERE s.model_id = $1 AND s.price_amount IS NOT NULL++ AND s.day >= (CURRENT_DATE - ($2::int || ' days')::interval)++ GROUP BY s.day ORDER BY s.day ASC`,++ [modelId, Math.max(1, Math.min(365, days))],++ );++ return rows.map((r) => ({++ day: iso(r.day).slice(0, 10),++ price: num(r.price),++ offers: Number(r.offers) || 0,++ checked_at: iso(r.checked_at),++ }));++ }++++ /** The size-price curve: the lowest all-in price per size, and what it costs against the model's middle. */++ async function curve(modelId) {++ const rows = await db.many(++ `SELECT o.size AS size,++ MIN(o.price_amount + COALESCE(o.shipping_amount, 0)) AS min_price,++ AVG(o.price_amount + COALESCE(o.shipping_amount, 0)) AS avg_price,++ COUNT(*)::int AS offers,++ COUNT(DISTINCT o.store_domain)::int AS stores,++ MAX(o.checked_at) AS checked_at++ FROM shoe_offers o++ WHERE o.model_id = $1 AND o.active AND o.size IS NOT NULL AND o.price_amount IS NOT NULL++ GROUP BY o.size`,++ [modelId],++ );++ const points = rows.map((r) => ({++ size: r.size,++ min_price: num(r.min_price),++ avg_price: num(r.avg_price),++ offers: Number(r.offers) || 0,++ stores: Number(r.stores) || 0,++ checked_at: iso(r.checked_at),++ })).sort((a, b) => a.size.localeCompare(b.size, 'en', { numeric: true }));++ if (!points.length) return { points: [], median: null };++ const prices = points.map((p) => p.min_price).filter((p) => p != null).sort((a, b) => a - b);++ const mid = prices.length ? prices[Math.floor((prices.length - 1) / 2)] : null;++ return {++ points: points.map((p) => ({++ ...p,++ vs_median_percent: mid && p.min_price != null ? Math.round((p.min_price - mid) * 10000) / 100 : null,++ })),++ median: mid,++ };++ }++++ /** The drops, soonest first. */++ async function listDrops({ from = null, to = null, limit = 60 } = {}) {++ const args = [];++ const where = [];++ if (from) { args.push(new Date(from).toISOString()); where.push(`d.release_at >= $${args.length}::timestamptz`); }++ if (to) { args.push(new Date(to).toISOString()); where.push(`d.release_at <= $${args.length}::timestamptz`); }++ const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';++ args.push(Math.max(1, Math.min(200, limit)));++ const rows = await db.many(`SELECT d.* FROM shoe_drops d ${clause} ORDER BY d.release_at ASC LIMIT $${args.length}`, args);++ return rows.map(wireDrop);++ }++++ /** The drops a source published in the last read, and one of them by its page id. */++ async function dropById(id) {++ const row = await db.maybe('SELECT * FROM shoe_drops WHERE id = $1', [String(id || '')]);++ return row ? wireDrop(row) : null;++ }++++ /** The official releases that match a model, soonest first (a cited release date on its page). */++ async function dropsForModel(model, { limit = 5 } = {}) {++ if (!model) return [];++ const rows = await db.many(++ `SELECT * FROM shoe_drops++ WHERE lower(brand) = lower($1) AND lower(model) = lower($2)++ AND release_at >= now() - interval '1 day'++ ORDER BY release_at ASC LIMIT $3`,++ [model.brand, model.model, Math.max(1, Math.min(20, limit))],++ );++ return rows.map(wireDrop);++ }++++ /** One drop from an official release page. */++ async function upsertDrop(input) {++ const brand = parse.cleanName(input.brand, 80);++ const model = parse.cleanName(input.model, 120);++ if (!brand || !model) return null;++ const sourceKey = String(input.sourceKey || '').trim();++ if (!sourceKey) return null;++ const sourceUrl = String(input.sourceUrl || '');++ if (!/^https?:\/\//i.test(sourceUrl)) return null;++ const base = parse.slugFor(brand, `${model} ${input.colourway || ''}`) || parse.slugFor(brand, model);++ const slug = `${base.slice(0, 72)}-${shortHash(sourceKey)}`.slice(0, 80).replace(/-$/, '');++ const row = await db.one(++ `INSERT INTO shoe_drops++ (id, slug, brand, model, colourway, release_at, price_amount, currency, sizes, source_name, source_url, source_key, fetched_at, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7, $8, $9::jsonb, $10, $11, $12, $13::timestamptz, $14::timestamptz, $14::timestamptz)++ ON CONFLICT (source_name, source_key) DO UPDATE SET++ release_at = EXCLUDED.release_at, price_amount = EXCLUDED.price_amount, currency = EXCLUDED.currency,++ sizes = EXCLUDED.sizes, colourway = EXCLUDED.colourway, source_url = EXCLUDED.source_url,++ fetched_at = EXCLUDED.fetched_at, updated_at = EXCLUDED.updated_at++ RETURNING *`,++ [++ s.newId('shd'), slug, brand, model, parse.cleanName(input.colourway, 60) || null,++ new Date(input.releaseAt).toISOString(), parse.amountOf(input.price), String(input.currency || 'USD'),++ JSON.stringify(Array.isArray(input.sizes) ? input.sizes : []),++ String(input.sourceName || 'official release page'), sourceUrl, sourceKey,++ new Date(input.fetchedAt || s.now()).toISOString(), s.iso(),++ ],++ );++ return row ? wireDrop(row) : null;++ }++++ /** A cited fact on a model. Only a source's own words; no fact without its URL. */++ async function addFact({ modelId, kind, label, value, sourceName, sourceUrl, fetchedAt }) {++ const k = String(kind || '');++ if (!['specs', 'fit', 'release', 'care'].includes(k)) return null;++ if (!/^https?:\/\//i.test(String(sourceUrl || ''))) return null;++ const row = await db.one(++ `INSERT INTO shoe_facts (id, model_id, kind, label, value, source_name, source_url, fetched_at, created_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::timestamptz, $9::timestamptz) RETURNING *`,++ [s.newId('shf'), modelId, k, parse.cleanName(label, 80), String(value).slice(0, 500),++ parse.cleanName(sourceName, 120), sourceUrl, new Date(fetchedAt || s.now()).toISOString(), s.iso()],++ );++ return row || null;++ }++++ async function factsOf(modelId) {++ const rows = await db.many('SELECT * FROM shoe_facts WHERE model_id = $1 ORDER BY kind ASC, label ASC', [modelId]);++ return rows.map((r) => ({++ kind: r.kind, label: r.label, value: r.value,++ source_name: r.source_name, source_url: r.source_url, fetched_at: iso(r.fetched_at),++ }));++ }++++ /** A person's watch: the price watch or the pasted listing. */++ async function createWatch(input) {++ const row = await db.one(++ `INSERT INTO shoe_watches++ (id, subject, kind, model_id, slug, size, condition, threshold_amount, currency, source_url, remote_id, status, note, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::timestamptz, $14::timestamptz)++ RETURNING *`,++ [++ s.newId('shw'), String(input.subject), String(input.kind), input.modelId || null, input.slug || null,++ input.size || null, input.condition || null, parse.amountOf(input.threshold), String(input.currency || 'USD'),++ input.sourceUrl || null, input.remoteId || null, String(input.status || 'active'), input.note || null, s.iso(),++ ],++ );++ return wireWatch(row);++ }++++ /** A person's watches, newest first. */++ async function listWatches(subject) {++ const rows = await db.many(++ `SELECT w.*, m.slug AS model_slug FROM shoe_watches w++ LEFT JOIN shoe_models m ON m.id = w.model_id++ WHERE w.subject = $1 ORDER BY w.created_at DESC`,++ [String(subject)],++ );++ return rows.map(wireWatch);++ }++++ /** The lowest all-in price for a model, optionally for one size, condition and store (the price a Watch reads). */++ async function priceFor(modelId, { size = null, condition = null } = {}) {++ const args = [modelId];++ const where = ['o.model_id = $1', 'o.active', 'o.price_amount IS NOT NULL'];++ if (size) { args.push(String(size)); where.push(`o.size = $${args.length}`); }++ if (condition) { args.push(String(condition)); where.push(`o.condition = $${args.length}`); }++ const row = await db.one(++ `SELECT MIN(o.price_amount + COALESCE(o.shipping_amount, 0)) AS all_in,++ MAX(o.checked_at) AS checked_at, COUNT(*)::int AS offers++ FROM shoe_offers o WHERE ${where.join(' AND ')}`,++ args,++ );++ return { price: num(row && row.all_in), checked_at: row ? iso(row.checked_at) : null, offers: Number((row && row.offers) || 0) };++ }++++ /** Record what a job did; the pages and /api/ready use it for an honest "last checked". */++ async function startJobRun({ job, sourceName, sourceUrl }) {++ const row = await db.one(++ `INSERT INTO shoe_job_runs (job, source_name, source_url, started_at)++ VALUES ($1, $2, $3, $4::timestamptz) RETURNING id`,++ [String(job), String(sourceName || job), sourceUrl || null, s.iso()],++ );++ return row ? row.id : null;++ }++ async function finishJobRun(id, { ok = true, counts = {}, error = null } = {}) {++ if (id == null) return;++ await db.exec(++ `UPDATE shoe_job_runs SET finished_at = $1::timestamptz, ok = $2, counts = $3::jsonb, error = $4 WHERE id = $5`,++ [s.iso(), !!ok, JSON.stringify(counts || {}), error ? String(error).slice(0, 500) : null, id],++ );++ }++ async function lastJobRuns() {++ const rows = await db.many(++ `SELECT DISTINCT ON (job) job, source_name, source_url, started_at, finished_at, ok, counts, error++ FROM shoe_job_runs ORDER BY job, started_at DESC`,++ );++ return rows.map((r) => ({++ job: r.job, source_name: r.source_name, source_url: r.source_url,++ started_at: iso(r.started_at), finished_at: iso(r.finished_at), ok: r.ok,++ counts: typeof r.counts === 'string' ? JSON.parse(r.counts) : r.counts, error: r.error || null,++ }));++ }++++ /** The number of rows a table holds (the seed report and the tests). */++ async function counts() {++ const row = await db.one(++ `SELECT (SELECT COUNT(*)::int FROM shoe_models) AS models,++ (SELECT COUNT(*)::int FROM shoe_offers) AS offers,++ (SELECT COUNT(*)::int FROM shoe_snapshots) AS snapshots,++ (SELECT COUNT(*)::int FROM shoe_drops) AS drops,++ (SELECT COUNT(*)::int FROM shoe_watches) AS watches,++ (SELECT COUNT(*)::int FROM shoe_facts) AS facts`,++ );++ return {++ models: Number(row.models) || 0,++ offers: Number(row.offers) || 0,++ snapshots: Number(row.snapshots) || 0,++ drops: Number(row.drops) || 0,++ watches: Number(row.watches) || 0,++ facts: Number(row.facts) || 0,++ };++ }++++ return {++ ensureModel, upsertOffer, writeSnapshot, listShoes, countShoes, facets, modelBySlug, modelById, offersOf, history,++ curve, listDrops, dropById, dropsForModel, upsertDrop, addFact, factsOf, createWatch, listWatches, priceFor,++ startJobRun, finishJobRun, lastJobRuns, counts, wireOffer, wireModel, wireDrop, wireWatch,++ };++}++++module.exports = { createCatalogue, wireOffer, wireModel, wireDrop, wireWatch };nnew file mode 100644@@@ -0,0 +1,123 @@++'use strict';++++/**++ * OpenVibe.Shoes configuration (open.shoes). Every value comes from the environment (production:++ * /etc/openvibe/shoes.env, see .env.example). Only environment variable NAMES appear in code and docs; secrets are++ * never logged.++ *++ * load(env) is pure so tests can build a config without touching process.env.++ */++require('dotenv').config();++const trim = (s) => String(s || '').replace(/\/+$/, '');++const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);++const bool = (v, def = false) => (v == null ? def : v === '1' || v === 'true' || v === 'yes');++++function load(env = process.env) {++ const nodeEnv = env.NODE_ENV || 'development';++ const isProduction = nodeEnv === 'production';++ const port = int(env.PORT, 5180);++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://open.shoes' : `http://localhost:${port}`));++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');++++ return {++ service: 'shoes',++ room: 'shoes',++ port,++ host: env.HOST || '127.0.0.1',++ nodeEnv,++ isProduction,++ baseUrl,++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,++ drill: bool(env.SHOES_DRILL),++ limits: {++ minute: Math.max(1, int(env.SHOES_LIMITS_MINUTE, 120)),++ hour: Math.max(1, int(env.SHOES_LIMITS_HOUR, 3000)),++ },++++ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role). In++ // development without DATABASE_URL an embedded PGlite database in data/pglite is used (SHOES_PGLITE_DIR++ // overrides the directory).++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.SHOES_PGLITE_DIR || '' },++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:shoes:' },++++ // OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.++ networkUrl,++ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),++ networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),++ audience: env.SHOES_AUDIENCE || 'open.shoes',++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'shoes',++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,++ scope: 'profile',++ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',++ },++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },++++ // OpenVibe.Events → this service's account export/deletion route. The secret signs a delivery++ // (comma-separated for rotation, 32+ characters each); unset turns POST /internal/events off (503).++ events: {++ secrets: String(env.SHOES_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),++ url: trim(env.SHOES_EVENTS_URL || env.EVENTS_URL || ''),++ endpoint: env.SHOES_EVENTS_ENDPOINT || '',++ intervalMs: Math.max(500, int(env.SHOES_EVENTS_INTERVAL_MS, 5000)),++ scope: env.SHOES_EVENTS_SCOPE || 'events.event.publish',++ },++++ // The sources the jobs read. OpenVibe.Deals is the network's own offer aggregator (its public API); the drop++ // source is a brand's or a store's own public release page. eBay's official Browse API is used only when a++ // key pair is set. No key, no source: that part is off with a plain note, never an error.++ sources: {++ dealsUrl: trim(env.SHOES_DEALS_URL || 'https://openvibe.deals'),++ offersPages: Math.max(1, Math.min(200, int(env.SHOES_OFFERS_PAGES, 20))),++ productLookups: Math.max(0, Math.min(500, int(env.SHOES_PRODUCT_LOOKUPS, 300))),++ dropsUrl: trim(env.SHOES_DROPS_URL || 'https://www.nike.com/launch'),++ dropsSourceName: env.SHOES_DROPS_SOURCE_NAME || 'Nike SNKRS launch calendar',++ dropFeeds: (() => {++ const raw = String(env.SHOES_DROP_FEEDS || '').trim();++ if (!raw) return [];++ try {++ const parsed = JSON.parse(raw);++ return Array.isArray(parsed) ? parsed.filter((x) => x && x.url) : [];++ } catch { return []; }++ })(),++ staleHours: Math.max(1, int(env.SHOES_STALE_HOURS, 48)),++ timeoutMs: Math.max(500, int(env.SHOES_UPSTREAM_TIMEOUT_MS, 15000)),++ requestGapMs: Math.max(0, int(env.SHOES_REQUEST_GAP_MS, 250)),++ ebay: {++ clientId: env.SHOES_EBAY_CLIENT_ID || '',++ clientSecret: env.SHOES_EBAY_CLIENT_SECRET || '',++ marketplace: env.SHOES_EBAY_MARKETPLACE || 'EBAY_US',++ apiUrl: trim(env.SHOES_EBAY_API_URL || 'https://api.ebay.com'),++ limit: Math.max(1, Math.min(200, int(env.SHOES_EBAY_LIMIT, 50))),++ categoryId: env.SHOES_EBAY_CATEGORY_ID || '93427',++ },++ },++++ // OpenVibe.Watch: the watch service that carries the alerts. Without a service token the watch is saved++ // here and marked "not connected" — a plain note, never an error.++ watch: {++ url: trim(env.SHOES_WATCH_URL || 'https://openvibe.watch'),++ internalUrl: trim(env.SHOES_WATCH_INTERNAL_URL || 'http://127.0.0.1:4730'),++ token: env.SHOES_WATCH_TOKEN || '',++ audience: env.SHOES_WATCH_AUDIENCE || 'openvibe.watch',++ capability: env.SHOES_WATCH_CAPABILITY || 'watch.watch.manage',++ everySec: Math.max(900, int(env.SHOES_WATCH_EVERY_SEC, 21600)),++ },++++ // OpenVibe.Today: the drops calendar's reminders.++ today: {++ url: trim(env.SHOES_TODAY_URL || 'https://openvibe.today'),++ internalUrl: trim(env.SHOES_TODAY_INTERNAL_URL || 'http://127.0.0.1:5100'),++ },++++ // The jobs. Each refuses to start under SHOES_DRILL; tests call their runOnce().++ jobs: {++ offersIntervalMs: Math.max(60_000, int(env.SHOES_OFFERS_INTERVAL_MS, 30 * 60_000)),++ dropsIntervalMs: Math.max(60_000, int(env.SHOES_DROPS_INTERVAL_MS, 6 * 60 * 60_000)),++ ebayIntervalMs: Math.max(60_000, int(env.SHOES_EBAY_INTERVAL_MS, 60 * 60_000)),++ },++ };++}++++module.exports = { load };nnew file mode 100644@@@ -0,0 +1,48 @@++'use strict';++++/**++ * OpenVibe.Shoes's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.++ * Nothing written here is ever a secret. An offer stores the store's own URL, the price and the time it was++ * checked; a watch stores a person's subject id, a threshold and the public URL they pasted.++ */++const fs = require('fs');++const path = require('path');++const { createDb } = require('openvibe-sdk/db');++const { ids } = require('openvibe-contracts');++++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');++++async function openDb(config, { log = console, registry } = {}) {++ if (!config.db.url) {++ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh today)');++ const dir = config.db.pgliteDir || DEV_PGLITE;++ log.warn(`[OpenVibe.Shoes] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);++ fs.mkdirSync(dir, { recursive: true });++ const db = createDb({ pglite: dir, service: 'shoes', registry, log });++ await db.migrate({ dir: MIGRATIONS, log });++ return db;++ }++ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');++ const owner = createDb({ url: config.db.directUrl, service: 'shoes-migrate', max: 1, log });++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++ return createDb({ url: config.db.url, service: 'shoes', registry, log });++}++++/** The store every module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */++function createStore(db, { now = () => Date.now() } = {}) {++ return {++ db,++ now,++ iso: () => new Date(now()).toISOString(),++ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,++ tx: async (fn) => await db.tx((t) => fn(t)),++ close: () => db.close(),++ };++}++++async function openStore(config, { now, log } = {}) {++ return createStore(await openDb(config, { log }), { now });++}++++module.exports = { openDb, openStore, createStore, MIGRATIONS };nnew file mode 100644@@@ -0,0 +1,144 @@++'use strict';++++/**++ * The drops a brand or a store publishes on its own release page. The parser is pure: it takes the page's HTML++ * and returns the releases it can prove, each with the time it was read. It never invents a date: a release with++ * no date in the page is not returned at all.++ *++ * The page this reads is a Nike SNKRS launch calendar (the brand's own public release page). Its data arrives as++ * a JSON payload inside the page; the rules below read the fields Shoes shows: the release moment, the model, the++ * colourway, the price and the size grid. A second source can be a plain RSS feed of a brand's or a store's own++ * releases.++ */++const parse = require('./parse');++++const BRAND_RULES = [++ { brand: 'Jordan', test: /^(air\s+)?jordan\b/i },++ { brand: 'Nike', test: /^(nike|air|kobe|lebron|dunk)/i },++];++++/** The brand of a release on a brand's own calendar. */++function brandFor(title, fallback = 'Nike') {++ const t = String(title || '').trim();++ for (const rule of BRAND_RULES) if (rule.test.test(t)) return rule.brand;++ return fallback;++}++++/** Undo the escaping the page puts its JSON payload through. */++function decode(text) {++ // The page puts its payload in a JavaScript string literal: unescape one level (structural quotes become++ // JSON quotes, inner quotes stay escaped), then turn the page's escaped slashes/ampersands into characters.++ const unescape = (value) => String(value || '').replace(/\\(u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.)/g, (m, c) => {++ if (c[0] === 'u' || c[0] === 'x') {++ const code = parseInt(c.slice(1), 16);++ return Number.isFinite(code) ? String.fromCharCode(code) : c;++ }++ return ({ n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', '0': '\0' })[c] ?? c;++ });++ return unescape(text).replace(/\\u002F/g, '/').replace(/\\u0026/g, '&');++}++++/** The size grid a release page published. */++function sizesIn(window) {++ const out = [];++ const re = /"nike_size":"([^"]+)"/g;++ let m;++ while ((m = re.exec(window)) !== null) {++ const size = parse.cleanSize(m[1]);++ if (size && !out.includes(size)) out.push(size);++ }++ return out;++}++++/** One field of the payload, as its own value. */++function field(window, name) {++ const m = window.match(new RegExp('"' + name + '":("(?:[^"\\\\]|\\\\.)*"|[0-9.]+|true|false|null)'));++ if (!m) return null;++ const raw = m[1];++ if (raw.startsWith('"')) {++ try { return JSON.parse(raw); } catch { return raw.slice(1, -1); }++ }++ if (raw === 'true') return true;++ if (raw === 'false') return false;++ if (raw === 'null') return null;++ return Number(raw);++}++++/**++ * The releases a launch calendar page publishes. Each one: { brand, model, colourway, releaseAt, price, sizes,++ * sourceKey }. A release without a date or without a model name is skipped: the page does not prove it.++ */++function parseLaunchCalendar(html, { sourceName = 'official release calendar', sourceUrl = '', fetchedAt = null } = {}) {++ const text = decode(html);++ const marks = [];++ const re = /"commerceStartDate":"([^"]+)"/g;++ let m;++ while ((m = re.exec(text)) !== null) marks.push({ at: m.index, date: m[1] });++ const out = [];++ for (let i = 0; i < marks.length; i++) {++ const start = marks[i].at;++ const end = i + 1 < marks.length ? marks[i + 1].at : text.length;++ const window = text.slice(start, end);++ const releaseAt = new Date(marks[i].date);++ if (Number.isNaN(releaseAt.getTime())) continue;++ const title = parse.cleanName(field(window, 'title'), 120);++ if (!title) continue;++ const brand = brandFor(title);++ const colourway = parse.colourwayOf(title);++ const model = parse.modelOf(title, brand);++ if (!model) continue;++ const full = field(window, 'fullPrice');++ const price = parse.amountOf(full != null ? full : field(window, 'msrp'));++ const sizes = sizesIn(window);++ const styleColor = field(window, 'styleColor') || '';++ const sourceKey = [title, styleColor, marks[i].date].join('|');++ out.push({++ brand, model, colourway,++ releaseAt: releaseAt.toISOString(),++ price,++ currency: 'USD',++ sizes,++ sourceKey,++ sourceName,++ sourceUrl,++ fetchedAt: fetchedAt || new Date().toISOString(),++ });++ }++ return out;++}++++/** A plain RSS feed: { title, link, pubDate } per item. Used by the extra feeds a host may configure. */++function parseRss(xml, { sourceName = 'official release feed', sourceUrl = '', fetchedAt = null } = {}) {++ const out = [];++ const items = String(xml || '').split(/<item\b/i).slice(1);++ for (const item of items) {++ const pick = (tag) => {++ const m = item.match(new RegExp('<' + tag + '[^>]*>([\\s\\S]*?)</' + tag + '>', 'i'));++ return m ? m[1].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').replace(/<[^>]+>/g, '').trim() : null;++ };++ const title = pick('title');++ const link = pick('link');++ const date = pick('pubDate') || pick('dc:date');++ if (!title || !date) continue;++ const when = new Date(date);++ if (Number.isNaN(when.getTime())) continue;++ const brand = parse.brandOf(title) || 'Unknown';++ const model = parse.modelOf(title, brand);++ if (!model) continue;++ out.push({++ brand, model,++ colourway: parse.colourwayOf(title),++ releaseAt: when.toISOString(),++ price: null,++ currency: 'USD',++ sizes: [],++ sourceKey: link || title,++ sourceName,++ sourceUrl: link || sourceUrl,++ fetchedAt: fetchedAt || new Date().toISOString(),++ });++ }++ return out;++}++++module.exports = { parseLaunchCalendar, parseRss, brandFor, decode, sizesIn, field };nnew file mode 100644@@@ -0,0 +1,227 @@++'use strict';++++/**++ * The rules that turn a source's words into Shoes' own fields. Pure: no clock, no database, no network. Every++ * rule here is a promise the site makes, so each one is unit tested (test/parse.test.js):++ *++ * isShoe whether a listing is footwear at all (a keyword, or a model Shoes knows)++ * brandOf which brand a title names (the source's own brand first, then a known list)++ * modelOf the model name inside a title, with the brand and the "running shoe" words taken off++ * colourwayOf a colourway the title quotes or the source names; null when it names none++ * slugFor /shoe/<brand>-<model>++ * sizeOf the size a title names ("Size 10.5", "US 9"); null when it names none++ * conditionOf new | used | deadstock | null (not stated)++ * amountOf a price as a number; null when it is not a price++ * allIn price + shipping when the store states shipping, else the price alone++ *++ * Nothing here guesses: a field the source did not give stays null and the page says "not known yet".++ */++++/** The brands Shoes recognises in a title. Longest first, so "New Balance" wins over "Balance". */++const BRANDS = Object.freeze([++ 'Adidas Originals', 'Air Jordan', 'Under Armour', 'New Balance', 'Dr. Martens', 'The North Face',++ 'Nike', 'Jordan', 'Adidas', 'Puma', 'Reebok', 'ASICS', 'Asics', 'Converse', 'Vans', 'Crocs', 'Skechers',++ 'Clarks', 'Merrell', 'Timberland', 'Steve Madden', 'Saucony', 'Brooks', 'Hoka', 'On', 'Fila', 'Mizuno',++ 'Diadora', 'K-Swiss', 'Lotto', 'Champion', 'Guess', 'Diesel', 'Lacoste', 'Osiris', 'Etnies', 'Keen',++ 'Wolverine', 'Reef', 'Olukai', 'UGG', 'Teva', 'Birkenstock', 'Bogs', 'Columbia', 'Hi-Tec', 'Keds',++ 'Nautica', 'Rip Curl', 'Quiksilver', 'DC', 'Yeezy', 'Salomon', 'Arc', 'Terry', 'Zara', 'Geox',++ 'Sperry', 'Cole Haan', 'ECCO', 'Dansko', 'Danner', 'Mephisto', 'Rieker', 'Josef Seibel', 'Naturalizer',++ 'LifeStride', 'SoftWalk', 'Alegria', 'Xero Shoes', 'Altra', 'Topo Athletic', 'Inov-8', 'La Sportiva',++]);++++const BRAND_ALIASES = Object.freeze({++ 'air jordan': 'Jordan',++ 'jordan brand': 'Jordan',++ 'nike sb': 'Nike',++ 'nike sportswear': 'Nike',++ 'adidas originals': 'Adidas Originals',++ 'new balance': 'New Balance',++ 'asics': 'ASICS',++ 'the north face': 'The North Face',++ 'dr martens': 'Dr. Martens',++ 'dr. martens': 'Dr. Martens',++ 'under armour': 'Under Armour',++ 'xero shoes': 'Xero Shoes',++});++++/** The words that make a listing footwear. A title must name one of these, or a model below, to enter the catalogue. */++const FOOTWEAR = new RegExp(`\\b(?:${[++ 'shoe', 'shoes', 'sneaker', 'sneakers', 'trainer', 'trainers', 'boot', 'boots', 'sandal', 'sandals',++ 'slide', 'slides', 'clog', 'clogs', 'loafer', 'loafers', 'heel', 'heels', 'pump', 'pumps', 'oxford',++ 'oxfords', 'mule', 'mules', 'flip[- ]?flop', 'flip[- ]?flops', 'footwear', 'cleat', 'cleats', 'slipper',++ 'slippers', 'espadrille', 'espadrilles', 'slingback', 'slingbacks', 'wedge', 'wedges', 'mary jane',++].join('|')})\\b`, 'i');++++/** A model word that only a shoe has. "Air Force 1" is a shoe even when the title drops the word. */++const SHOE_MODELS = new RegExp([++ 'air max', 'air force', 'air jordan', 'jordan \\d', 'dunk', 'yeezy', 'ultraboost', 'ultra boost',++ 'pegasus', 'gel[- ]kayano', 'gel[- ]nimbus', 'gel[- ]cumulus', 'samba', 'gazelle', 'stan smith',++ 'superstar', 'chuck taylor', 'old skool', 'sk8', 'air monarch', 'air trainer', 'metcon', 'free run',++ 'zoom fly', 'vaporfly', 'alphafly', '990', '991', '992', '993', '574', '327', '9060', '550',++ 'cloudfoam', 'nmd', 'forum low', 'campus', 'sl 72', 'japan s', 'gazelle indoor', 'new balance 1906',++ 'on cloud', 'cloudmonster', 'cloudrunner', 'hoka bondi', 'hoka clifton', 'salomon xt', 'salomon speedcross',++ 'blundstone', 'chelsea boot', 'timberland 6', 'doc martens 1460', 'birkenstock boston', 'birkenstock arizona',++].join('|'), 'i');++++/** Leading words a title uses for the person it is for, not for the model. */++const PERSON_WORDS = /^(?:(?:men'?s|women'?s|kids'?|kid'?s|boys'?|girls'?|unisex|toddler|infant|baby|big kid|little kid|youth)\s+)+/i;++++/** Trailing words a title uses for the kind of thing it is, not for the model. */++const TYPE_WORDS = /(?:\s*[-–—,:]\s*|\s+)(?:men'?s|women'?s|kids'?|toddler|wide|medium|regular|standard|running shoe|running shoes|sneaker|sneakers|shoe|shoes|trainer|trainers|boot|boots|sandal|sandals|slide|slides|loafer|loafers|slipper|slippers|clog|clogs)\s*$/i;++++const SIZE_RE = [++ /\bsize\s*[:.]?\s*(\d{1,2}(?:\.\d)?)\b/i,++ /\bus\s*(\d{1,2}(?:\.\d)?)\b/i,++ /\b(\d{1,2}(?:\.\d)?)\s*(?:us|mens|mens'|womens|womens')\b/i,++ /\b(\d{1,2}(?:\.\d)?)\s*(?:men|women|kids?)\b/i,++];++++/** A clean name: spaces collapsed, no trailing punctuation, at most `max` characters. */++function cleanName(value, max = 120) {++ const s = String(value == null ? '' : value).replace(/\s+/g, ' ').trim().replace(/[\s.,;:]+$/, '');++ return s.slice(0, max);++}++++/** The slug of a model: /shoe/<brand>-<model>. Lower case, words joined by a dash. */++function slugFor(brand, model) {++ const parts = [brand, model]++ .map((x) => String(x == null ? '' : x).toLowerCase())++ .join(' ')++ .replace(/[^a-z0-9]+/g, '-')++ .replace(/-+/g, '-')++ .replace(/^-|-$/g, '');++ return parts.slice(0, 80).replace(/-$/, '');++}++++/** The brand a title names: the source's own brand first (when it is a brand we know), else the title's. */++function brandOf(title, brandHint) {++ const hint = cleanName(brandHint, 80);++ if (hint) {++ const alias = BRAND_ALIASES[hint.toLowerCase()];++ if (alias) return alias;++ const known = BRANDS.find((b) => b.toLowerCase() === hint.toLowerCase());++ return known || hint;++ }++ const t = String(title == null ? '' : title);++ const low = t.toLowerCase();++ for (const b of BRANDS) {++ const key = b.toLowerCase();++ if (low.startsWith(key + ' ') || low.startsWith(key + '-') || low.startsWith(key + ',')) return BRAND_ALIASES[key] || b;++ }++ for (const b of BRANDS) {++ const key = b.toLowerCase();++ if (low.includes(' ' + key + ' ')) return BRAND_ALIASES[key] || b;++ }++ return null;++}++++/** Whether a listing is footwear: a footwear word, or a model only a shoe has. */++function isShoe(...titles) {++ const t = titles.filter(Boolean).join(' ');++ return FOOTWEAR.test(t) || SHOE_MODELS.test(t);++}++++/** A colourway named by the source, or quoted in the title ("Air Max 90 \"Medium Olive\""). */++function colourwayOf(title, hint) {++ const h = cleanName(hint, 80);++ if (h) return h;++ const quoted = String(title == null ? '' : title).match(/["“”]([^"“”]{2,60})["“”]/);++ if (quoted) return cleanName(quoted[1], 60) || null;++ return null;++}++++/**++ * The model name inside a title: the person words, the brand and the kind words taken off, a quoted colourway++ * removed. "Women's Nike Air Max 90 Running Shoe" → "Air Max 90".++ */++function modelOf(title, brand) {++ let t = cleanName(title, 200);++ t = t.replace(PERSON_WORDS, '');++ // A quoted colourway is the colourway, not the model.++ t = t.replace(/\s*["“”][^"“”]{2,60}["“”]/g, '').trim();++ const b = cleanName(brand, 80);++ if (b && b.toLowerCase() === 'jordan') t = t.replace(/^air\s+/i, '').trim();++ if (b) {++ const key = b.toLowerCase();++ const idx = t.toLowerCase().indexOf(key);++ if (idx >= 0) {++ // Remove the brand once, wherever it sits after the person words ("Air Jordan 14 Retro" → "14 Retro").++ t = `${t.slice(0, idx)} ${t.slice(idx + key.length)}`.replace(/\s+/g, ' ').trim();++ }++ }++ t = t.replace(/\s+[-–—]\s+.*$/, '').trim();++ // A kind word in the middle is still the kind, not the model: "Samba OG Shoes Size 10.5".++ t = t.replace(/\s+(?:running shoes?|sneakers?|shoes?|trainers?|boots?|sandals?|slides?|loafers?|slippers?|clogs?)\s+(?=size\b|us\b)/i, ' ').trim();++ for (let i = 0; i < 3; i++) t = t.replace(TYPE_WORDS, '').trim();++ return cleanName(t, 120);++}++++/** The size a title names, as its own string ("10.5"); null when the title names none. */++function sizeOf(title) {++ const t = String(title == null ? '' : title);++ for (const re of SIZE_RE) {++ const m = t.match(re);++ if (m) return cleanSize(m[1]);++ }++ return null;++}++++/** A size as the source wrote it, normalised: "10.5", "10.5C", "5Y". */++function cleanSize(value) {++ const s = String(value == null ? '' : value).trim().toUpperCase();++ const m = s.match(/^(\d{1,2}(?:\.\d)?)([A-Z]*)$/);++ if (!m) return s ? s.slice(0, 12) : null;++ return m[1] + m[2];++}++++/** new | used | deadstock | null (the source did not say). */++function conditionOf(value) {++ const s = String(value == null ? '' : value).trim().toLowerCase();++ if (!s) return null;++ if (/^(new|brand new|bnwt|new with tags|new with box|new in box)/.test(s)) return 'new';++ if (/^(used|pre-?owned|preowned|second hand)/.test(s)) return 'used';++ if (/^(deadstock|ds|dead stock)/.test(s)) return 'deadstock';++ return null;++}++++/** A price as a number, or null when it is not one. "$1,299.00" → 1299. */++function amountOf(value) {++ if (value == null || value === '') return null;++ if (typeof value === 'number') return Number.isFinite(value) ? value : null;++ const m = String(value).replace(/[^0-9.]/g, '');++ if (!m) return null;++ const n = Number.parseFloat(m);++ return Number.isFinite(n) ? n : null;++}++++/**++ * The all-in price: the price plus the shipping when the store states it. The flag says whether the shipping++ * was known, so a page never claims a shipping it did not see.++ */++function allIn(price, shipping) {++ const p = amountOf(price);++ const sh = amountOf(shipping);++ if (p == null) return { amount: null, shippingKnown: false, shipping: null };++ if (sh == null) return { amount: p, shippingKnown: false, shipping: null };++ return { amount: Math.round((p + sh) * 100) / 100, shippingKnown: true, shipping: sh };++}++++/** The day a check belongs to (UTC), the key of the price history. */++function dayOf(when) {++ const d = when instanceof Date ? when : new Date(when);++ if (Number.isNaN(d.getTime())) return null;++ return d.toISOString().slice(0, 10);++}++++/** A price is stale when it was checked longer ago than the hours allowed. */++function isStale(checkedAt, nowMs, staleHours) {++ const t = checkedAt instanceof Date ? checkedAt.getTime() : Date.parse(checkedAt);++ if (!Number.isFinite(t)) return false;++ const hours = Number(staleHours) > 0 ? Number(staleHours) : 48;++ return nowMs - t > hours * 3600_000;++}++++module.exports = {++ BRANDS, BRAND_ALIASES, SIZE_RE, FOOTWEAR, SHOE_MODELS,++ cleanName, slugFor, brandOf, modelOf, colourwayOf, isShoe, sizeOf, cleanSize, conditionOf, amountOf, allIn, dayOf, isStale,++};nnew file mode 100644@@@ -0,0 +1,190 @@++'use strict';++++/**++ * /api/v1 — OpenVibe.Shoes' JSON API.++ *++ * GET /shoes?q&brand&size&condition&store&sort&page&limit public search, with facets++ * GET /shoes/:slug offers, own history, size curve, cited facts++ * GET /shoes/:slug/price?size=&condition= the lowest all-in price (a Watch polls this)++ * GET /drops?from&to official release dates, soonest first++ * GET /drops/:id one release++ * GET /me/watches the signed-in person's watches++ * POST /me/watches a price watch through OpenVibe.Watch++ * POST /me/track a pasted listing link through Watch's rules++ * GET /status the last read of each source++ * GET /openapi.json this API as OpenAPI 3.1++ *++ * Errors are application/problem+json with a `code`. A person is the shoes_at session cookie or a Network user++ * token as a Bearer; a session write must come from open.shoes itself (403 shoes.cross_site).++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { asyncRouter } = require('./router');++const { openapi } = require('./openapi');++++const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;++const SORTS = new Set(['recent', 'drop', 'price', 'price_desc', 'popular', 'name']);++const CONDITIONS = new Set(['new', 'used', 'deadstock']);++++function createApi(ctx) {++ const { config, catalogue, principal, upstreams } = ctx;++ const r = asyncRouter();++ r.use(express.json({ limit: '64kb' }));++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++ r.use(principal.middleware);++++ const fail = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });++ const str = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '');++++ r.get('/shoes', async (req, res) => {++ const page = Math.max(1, Math.min(200, parseInt(req.query.page, 10) || 1));++ const limit = Math.max(1, Math.min(100, parseInt(req.query.limit, 10) || 24));++ const sort = SORTS.has(req.query.sort) ? req.query.sort : 'recent';++ const filters = {++ q: str(req.query.q, 80) || null,++ brand: str(req.query.brand, 80) || null,++ size: str(req.query.size, 12) || null,++ condition: CONDITIONS.has(req.query.condition) ? req.query.condition : null,++ store: str(req.query.store, 120) || null,++ };++ const [shoes, facets, total] = await Promise.all([++ catalogue.listShoes({ ...filters, sort, limit, offset: (page - 1) * limit }),++ catalogue.facets(),++ catalogue.countShoes(filters),++ ]);++ res.set('Cache-Control', 'public, max-age=60');++ return res.json({ shoes, facets, page, per_page: limit, total, sort, filters });++ });++++ r.get('/shoes/:slug', async (req, res) => {++ const slug = str(req.params.slug, 80);++ if (!SLUG.test(slug)) return fail(res, req, 404, 'shoe.not_found', 'No such shoe.');++ const model = await catalogue.modelBySlug(slug);++ if (!model) return fail(res, req, 404, 'shoe.not_found', 'No such shoe.');++ const days = Math.max(1, Math.min(365, parseInt(req.query.days, 10) || 90));++ const [offers, history, curve, facts, drops] = await Promise.all([++ catalogue.offersOf(model.id),++ catalogue.history(model.id, { days }),++ catalogue.curve(model.id),++ catalogue.factsOf(model.id),++ catalogue.dropsForModel(model),++ ]);++ const prices = offers.map((o) => o.all_in).filter((p) => p != null).sort((a, b) => a - b);++ const shoe = catalogue.wireModel({++ ...model,++ price_min: prices[0] ?? null,++ price_max: prices[prices.length - 1] ?? null,++ offer_count: offers.length,++ stale_count: offers.filter((o) => o.stale).length,++ checked_at: offers.map((o) => o.checked_at).filter(Boolean).sort().pop() || null,++ });++ res.set('Cache-Control', 'public, max-age=60');++ return res.json({ shoe, offers, history, curve, facts, drops });++ });++++ r.get('/shoes/:slug/price', async (req, res) => {++ const slug = str(req.params.slug, 80);++ if (!SLUG.test(slug)) return fail(res, req, 404, 'shoe.not_found', 'No such shoe.');++ const model = await catalogue.modelBySlug(slug);++ if (!model) return fail(res, req, 404, 'shoe.not_found', 'No such shoe.');++ const size = str(req.query.size, 12) || null;++ const condition = CONDITIONS.has(req.query.condition) ? req.query.condition : null;++ const out = await catalogue.priceFor(model.id, { size, condition });++ res.set('Cache-Control', 'no-store');++ return res.json({ slug, size, condition, price: out.price, currency: 'USD', checked_at: out.checked_at, offers: out.offers });++ });++++ r.get('/drops', async (req, res) => {++ const from = req.query.from ? new Date(String(req.query.from)) : new Date(Date.now() - 3600_000);++ const to = req.query.to ? new Date(String(req.query.to)) : null;++ if (Number.isNaN(from.getTime()) || (to && Number.isNaN(to.getTime()))) return fail(res, req, 400, 'request.invalid', 'from and to must be ISO times.');++ if (to && from.getTime() > to.getTime()) return fail(res, req, 400, 'request.invalid', 'from must not be later than to.');++ const drops = await catalogue.listDrops({ from: from.toISOString(), to: to ? to.toISOString() : null, limit: 200 });++ res.set('Cache-Control', 'public, max-age=60');++ return res.json({ from: from.toISOString(), to: to ? to.toISOString() : null, drops });++ });++++ r.get('/drops/:id', async (req, res) => {++ const drop = await catalogue.dropById(str(req.params.id, 40));++ if (!drop) return fail(res, req, 404, 'drop.not_found', 'No such drop.');++ return res.json(drop);++ });++++ r.get('/me/watches', principal.requirePerson, async (req, res) => {++ const watches = await catalogue.listWatches(req.principal.subject);++ return res.json({ watches });++ });++++ r.post('/me/watches', principal.requirePerson, async (req, res) => {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ const slug = str(b.slug, 80);++ const threshold = Number(b.threshold);++ if (!SLUG.test(slug)) return fail(res, req, 400, 'watch.invalid', 'slug must be a shoe slug from /api/v1/shoes.');++ if (!Number.isFinite(threshold) || threshold <= 0 || threshold > 1_000_000) return fail(res, req, 400, 'watch.invalid', 'threshold must be a positive amount.');++ const size = str(b.size, 12) || null;++ const condition = CONDITIONS.has(b.condition) ? b.condition : null;++ const model = await catalogue.modelBySlug(slug);++ if (!model) return fail(res, req, 404, 'shoe.not_found', 'No such shoe.');++ const qs = new URLSearchParams({ size: size || '', condition: condition || '' });++ const feedUrl = `${String(config.baseUrl).replace(/\/+$/, '')}/api/v1/shoes/${encodeURIComponent(slug)}/price?${qs.toString()}`;++ const body = {++ name: `Shoes: ${model.brand} ${model.model}${size ? ` size ${size}` : ''} below $${threshold}`.slice(0, 120),++ source: { kind: 'api', url: feedUrl },++ cadence: { every_sec: config.watch.everySec },++ extraction: { kind: 'json', value_path: 'price' },++ condition: { op: 'lt', value: threshold },++ action: [{ kind: 'notification', category: 'service', priority: 'normal' }],++ };++ const out = await upstreams.createWatch(body, { subject: req.principal.subject });++ const watch = await catalogue.createWatch({++ subject: req.principal.subject, kind: 'price', modelId: model.id, slug, size, condition, threshold,++ currency: 'USD', remoteId: out.ok && out.watch ? out.watch.id : null,++ status: out.ok ? 'active' : 'not_connected', note: out.ok ? null : out.detail,++ });++ return res.status(201).json({ watch, remote: out.ok ? out.watch : null, note: out.ok ? null : out.detail });++ });++++ r.post('/me/track', principal.requirePerson, async (req, res) => {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ const checkedUrl = await upstreams.checkPastedUrl(b.url);++ if (!checkedUrl.ok) return fail(res, req, 400, checkedUrl.code, checkedUrl.detail);++ const body = {++ name: `Shoes: a pasted listing (${checkedUrl.host})`.slice(0, 120),++ source: { kind: 'http', url: checkedUrl.url, method: 'GET' },++ cadence: { every_sec: config.watch.everySec },++ extraction: { kind: 'text' },++ condition: { op: 'changed' },++ action: [{ kind: 'notification', category: 'service', priority: 'normal' }],++ };++ const out = await upstreams.createWatch(body, { subject: req.principal.subject });++ const watch = await catalogue.createWatch({++ subject: req.principal.subject, kind: 'link', sourceUrl: checkedUrl.url,++ remoteId: out.ok && out.watch ? out.watch.id : null,++ status: out.ok ? 'active' : 'not_connected', note: out.ok ? null : out.detail,++ });++ return res.status(201).json({ watch, remote: out.ok ? out.watch : null, note: out.ok ? null : out.detail });++ });++++ r.get('/status', async (_req, res) => {++ const [counts, jobs, facets] = await Promise.all([catalogue.counts(), catalogue.lastJobRuns(), catalogue.facets()]);++ res.set('Cache-Control', 'public, max-age=30');++ return res.json({++ service: 'shoes', counts,++ facets: { models: facets.models, offers: facets.offers, stores: facets.stores.length, drops: facets.drops, checked_at: facets.checked_at },++ jobs,++ sources: {++ deals: config.sources.dealsUrl,++ drops: config.sources.dropsUrl,++ ebay: upstreams.ebayConfigured() ? 'configured' : 'off (no key pair)',++ watch: config.watch.token ? 'configured' : 'off (no service token)',++ },++ });++ });++++ r.get('/openapi.json', (req, res) => res.json(openapi(config)));++++ r.use((req, res) => fail(res, req, 404, 'route.not_found', 'No such API route.'));++ return r;++}++++module.exports = { createApi };nnew file mode 100644@@@ -0,0 +1,122 @@++'use strict';++++/**++ * Crawl artifacts for open.shoes: robots.txt, sitemap.xml, llms.txt and llms-full.txt. The model pages, the++ * catalogue and the drops are for search engines and AI crawlers; sign-in, the API, account pages and the internal++ * route are not. A sitemap lists every model Shoes knows (up to 100 in the artifact; the API pages the rest).++ */++const fs = require('fs');++const path = require('path');++const seo = require('openvibe-shared/seo');++const cache = require('openvibe-shared/cache-policy');++const { asyncRouter } = require('./router');++++const SITE_NAME = 'OpenVibe.Shoes';++const HOST = 'open.shoes';++const DESCRIPTION = 'OpenVibe.Shoes is every shoe, every market, the right price: one page per model with every store\'s all-in price, a price history from Shoes\' own daily snapshots, the size-price curve and a watch for your size.';++const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics', '/watches', '/track'];++++function siteUpdated() {++ try { return String(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated || '').slice(0, 10) || null; } catch { return null; }++}++++function homeJsonLd(config) {++ const site = String(config.baseUrl || `https://${HOST}`).replace(/\/+$/, '');++ return [++ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION, searchUrl: `${site}/shoes?q={q}` }),++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'ShoppingApplication', keywords: 'shoes, sneakers, all-in price, price history, size price, drops calendar, price watch, open data' }),++ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),++ ];++}++++function productJsonLd(config, model, offers, low) {++ const site = String(config.baseUrl || `https://${HOST}`).replace(/\/+$/, '');++ const url = `${site}/shoe/${model.slug}`;++ const prices = offers.map((o) => o.all_in).filter((p) => p != null);++ const out = seo.jsonLd.product({++ name: `${model.brand} ${model.model}`,++ url,++ description: `${model.brand} ${model.model}: every store's all-in price, the price history and the size-price curve.`,++ brand: model.brand,++ offers: prices.length ? {++ '@type': 'AggregateOffer',++ priceCurrency: 'USD',++ lowPrice: Math.min(...prices),++ highPrice: Math.max(...prices),++ offerCount: offers.length,++ availability: 'https://schema.org/InStock',++ url,++ } : undefined,++ });++ out['@context'] = 'https://schema.org';++ return out;++}++++function publicPages(models = []) {++ const pages = [++ { path: '/', changefreq: 'hourly', priority: 1.0, title: 'OpenVibe.Shoes home', text: 'Every shoe, every market, the right price: all-in prices, the price history and the size-price curve.' },++ { path: '/shoes', changefreq: 'hourly', priority: 0.9, title: 'Every shoe', text: 'Search every model by brand, size and condition; every offer is all-in with its store and the time it was checked.' },++ { path: '/drops', changefreq: 'daily', priority: 0.8, title: 'The drops calendar', text: 'Official shoe release dates, each cited to the brand\'s or store\'s own release page, with a reminder on OpenVibe.Today.' },++ { path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The Shoes API', text: 'The open JSON API for shoes, offers, history, curves, drops and watches, and the OpenAPI document.' },++ ];++ for (const m of models) {++ pages.push({++ path: `/shoe/${m.slug}`, changefreq: 'daily', priority: 0.8,++ title: `${m.brand} ${m.model}`, text: `${m.brand} ${m.model}: ${m.offer_count} offer${m.offer_count === 1 ? '' : 's'}${m.price_min != null ? `, from $${Number(m.price_min).toFixed(2)} all-in` : ''}.`,++ });++ }++ return pages;++}++++function createDiscoveryRoutes(ctx) {++ const { config, catalogue } = ctx;++ const r = asyncRouter();++ const base = () => String(config.baseUrl || `https://${HOST}`).replace(/\/+$/, '');++++ r.get('/robots.txt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }));++ res.send(seo.robotsTxt({ sitemaps: [`${base()}/sitemap.xml`], disallow: DISALLOW }));++ });++++ r.get('/sitemap.xml', async (_req, res) => {++ const models = await catalogue.listShoes({ sort: 'recent', limit: 100 });++ const urls = publicPages(models).map((p) => ({ loc: `${base()}${p.path}`, lastmod: siteUpdated() || undefined, changefreq: p.changefreq, priority: p.priority }));++ res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 600 }));++ res.send(seo.sitemapXml(urls));++ });++++ r.get('/llms.txt', (_req, res) => {++ const pages = publicPages([]);++ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 600 }));++ res.send(seo.llmsTxt({++ name: SITE_NAME,++ summary: DESCRIPTION,++ details: 'Public model pages, a public JSON API and an OpenAPI document. No sign-in is needed for the public reads; a price watch and a pasted listing link need a person.',++ sections: [++ { title: 'Pages', links: pages.map((p) => ({ title: p.title, url: `${base()}${p.path}`, note: p.text })) },++ { title: 'API', links: [++ { title: 'Search shoes', url: `${base()}/api/v1/shoes?q=air+max` },++ { title: 'One shoe', url: `${base()}/api/v1/shoes/nike-air-max-90` },++ { title: 'Drops', url: `${base()}/api/v1/drops` },++ { title: 'OpenAPI', url: `${base()}/api/openapi.json` },++ ] },++ ],++ }));++ });++++ r.get('/llms-full.txt', async (_req, res) => {++ const models = await catalogue.listShoes({ sort: 'recent', limit: 30 });++ const pages = publicPages(models);++ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 600 }));++ res.send(seo.llmsFull({++ site: SITE_NAME,++ summary: DESCRIPTION,++ base: base(),++ sections: [{ title: 'Pages', pages: pages.map((p) => ({ title: p.title, url: `${base()}${p.path}`, text: p.text })) }],++ }));++ });++++ return r;++}++++module.exports = { createDiscoveryRoutes, homeJsonLd, productJsonLd, publicPages, SITE_NAME, HOST, DESCRIPTION };nnew file mode 100644@@@ -0,0 +1,176 @@++'use strict';++++/**++ * The OpenAPI 3.1 document for the Shoes API. Kept as data, not a framework: every route the service answers is++ * listed here with its parameters and an honest answer shape. The servers are this host and the production host.++ */++function openapi(config = {}) {++ const base = String(config.baseUrl || 'https://open.shoes').replace(/\/+$/, '');++ const problem = { $ref: '#/components/schemas/Problem' };++ const params = {++ q: { name: 'q', in: 'query', schema: { type: 'string', maxLength: 80 }, description: 'A brand, model or name fragment.' },++ brand: { name: 'brand', in: 'query', schema: { type: 'string', maxLength: 80 } },++ size: { name: 'size', in: 'query', schema: { type: 'string', maxLength: 12 } },++ condition: { name: 'condition', in: 'query', schema: { type: 'string', enum: ['new', 'used', 'deadstock'] } },++ store: { name: 'store', in: 'query', schema: { type: 'string', maxLength: 120 } },++ sort: { name: 'sort', in: 'query', schema: { type: 'string', enum: ['recent', 'drop', 'price', 'price_desc', 'popular', 'name'], default: 'recent' } },++ page: { name: 'page', in: 'query', schema: { type: 'integer', minimum: 1, default: 1 } },++ limit: { name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 100, default: 24 } },++ slug: { name: 'slug', in: 'path', required: true, schema: { type: 'string', maxLength: 80 } },++ from: { name: 'from', in: 'query', schema: { type: 'string', format: 'date-time' } },++ to: { name: 'to', in: 'query', schema: { type: 'string', format: 'date-time' } },++ };++ const ok = (schema) => ({ 200: { description: 'OK', content: { 'application/json': { schema } } } });++ const bad = { 400: { description: 'Bad request', content: { 'application/problem+json': { schema: problem } } }, 404: { description: 'Not found', content: { 'application/problem+json': { schema: problem } } } };++ return {++ openapi: '3.1.0',++ info: {++ title: 'OpenVibe.Shoes API',++ version: '0.1.0',++ summary: 'Every shoe, every market, the right price.',++ description: 'Shoes aggregates official store offers into one all-in price per variant, keeps its own daily price snapshots, and exposes the drops calendar and a person\'s watches. Public reads take no key. There are no affiliate links and no Marketplace scraping.',++ license: { name: 'AGPL-3.0-only', url: 'https://www.gnu.org/licenses/agpl-3.0.html' },++ },++ servers: [{ url: base }, { url: 'https://open.shoes' }],++ tags: [{ name: 'shoes' }, { name: 'drops' }, { name: 'me' }, { name: 'status' }],++ paths: {++ '/api/v1/shoes': {++ get: {++ tags: ['shoes'], summary: 'Search the catalogue', operationId: 'listShoes',++ parameters: [params.q, params.brand, params.size, params.condition, params.store, params.sort, params.page, params.limit],++ responses: { ...ok({ $ref: '#/components/schemas/ShoeList' }), ...bad },++ },++ },++ '/api/v1/shoes/{slug}': {++ get: {++ tags: ['shoes'], summary: 'One model with its offers, history, curve and cited facts', operationId: 'getShoe',++ parameters: [params.slug],++ responses: { ...ok({ $ref: '#/components/schemas/ShoePage' }), ...bad },++ },++ },++ '/api/v1/shoes/{slug}/price': {++ get: {++ tags: ['shoes'], summary: 'The lowest all-in price for a model (and size)', operationId: 'getShoePrice',++ parameters: [params.slug, params.size, params.condition],++ responses: { ...ok({ $ref: '#/components/schemas/Price' }), ...bad },++ },++ },++ '/api/v1/drops': {++ get: {++ tags: ['drops'], summary: 'The official release dates', operationId: 'listDrops',++ parameters: [params.from, params.to],++ responses: { ...ok({ type: 'object', properties: { drops: { type: 'array', items: { $ref: '#/components/schemas/Drop' } } } }), ...bad },++ },++ },++ '/api/v1/drops/{id}': {++ get: {++ tags: ['drops'], summary: 'One drop', operationId: 'getDrop',++ parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],++ responses: { ...ok({ $ref: '#/components/schemas/Drop' }), ...bad },++ },++ },++ '/api/v1/me/watches': {++ get: {++ tags: ['me'], summary: 'The signed-in person\'s watches', operationId: 'listWatches',++ security: [{ bearerAuth: [] }, { cookieAuth: [] }],++ responses: { ...ok({ type: 'object', properties: { watches: { type: 'array', items: { $ref: '#/components/schemas/Watch' } } } }), 401: { description: 'Not signed in', content: { 'application/problem+json': { schema: problem } } } },++ },++ post: {++ tags: ['me'], summary: 'Tell me when this drops below $X at my size', operationId: 'createWatch',++ security: [{ bearerAuth: [] }, { cookieAuth: [] }],++ requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/WatchRequest' } } } },++ responses: { 201: { description: 'Created', content: { 'application/json': { schema: { type: 'object', properties: { watch: { $ref: '#/components/schemas/Watch' }, remote: { type: 'object', nullable: true } } } } } }, ...bad },++ },++ },++ '/api/v1/me/track': {++ post: {++ tags: ['me'], summary: 'Track a pasted listing link through OpenVibe.Watch', operationId: 'trackLink',++ security: [{ bearerAuth: [] }, { cookieAuth: [] }],++ requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['url'], properties: { url: { type: 'string', format: 'uri', maxLength: 2000 } } } } } },++ responses: { 201: { description: 'Created', content: { 'application/json': { schema: { type: 'object', properties: { watch: { $ref: '#/components/schemas/Watch' }, remote: { type: 'object', nullable: true } } } } } }, ...bad },++ },++ },++ '/api/v1/status': {++ get: {++ tags: ['status'], summary: 'The last read of each source', operationId: 'getStatus',++ responses: { ...ok({ type: 'object', properties: { counts: { type: 'object' }, jobs: { type: 'array', items: { type: 'object' } } } }) },++ },++ },++ },++ components: {++ securitySchemes: {++ bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },++ cookieAuth: { type: 'apiKey', in: 'cookie', name: 'shoes_at' },++ },++ schemas: {++ Problem: { type: 'object', required: ['type', 'title', 'status', 'code'], properties: { type: { type: 'string' }, title: { type: 'string' }, status: { type: 'integer' }, code: { type: 'string' }, detail: { type: 'string' } } },++ Offer: {++ type: 'object',++ properties: {++ id: { type: 'string' }, store: { type: 'string' }, store_name: { type: 'string', nullable: true },++ title: { type: 'string' }, url: { type: 'string', format: 'uri' },++ price: { type: 'number', nullable: true }, list_price: { type: 'number', nullable: true },++ shipping: { type: 'number', nullable: true }, shipping_known: { type: 'boolean' }, all_in: { type: 'number', nullable: true },++ drop_from_list: { type: 'number', nullable: true }, currency: { type: 'string' },++ condition: { type: 'string', nullable: true }, size: { type: 'string', nullable: true }, colourway: { type: 'string', nullable: true },++ checked_at: { type: 'string', format: 'date-time' }, stale: { type: 'boolean' }, source: { type: 'string' },++ },++ },++ Shoe: {++ type: 'object',++ properties: {++ id: { type: 'string' }, slug: { type: 'string' }, brand: { type: 'string' }, model: { type: 'string' }, name: { type: 'string' },++ price_min: { type: 'number', nullable: true }, price_max: { type: 'number', nullable: true },++ offer_count: { type: 'integer' }, store_count: { type: 'integer' }, stale_count: { type: 'integer' },++ checked_at: { type: 'string', format: 'date-time', nullable: true }, drop_amount: { type: 'number', nullable: true }, drop_percent: { type: 'number', nullable: true },++ },++ },++ ShoeList: {++ type: 'object',++ properties: {++ shoes: { type: 'array', items: { $ref: '#/components/schemas/Shoe' } },++ page: { type: 'integer' }, per_page: { type: 'integer' }, total: { type: 'integer' },++ facets: { type: 'object' },++ },++ },++ ShoePage: {++ type: 'object',++ properties: {++ shoe: { $ref: '#/components/schemas/Shoe' },++ offers: { type: 'array', items: { $ref: '#/components/schemas/Offer' } },++ history: { type: 'array', items: { type: 'object', properties: { day: { type: 'string', format: 'date' }, price: { type: 'number' }, offers: { type: 'integer' } } } },++ curve: { type: 'object', properties: { median: { type: 'number', nullable: true }, points: { type: 'array', items: { type: 'object' } } } },++ facts: { type: 'array', items: { type: 'object', properties: { kind: { type: 'string' }, label: { type: 'string' }, value: { type: 'string' }, source_name: { type: 'string' }, source_url: { type: 'string' }, fetched_at: { type: 'string' } } } },++ drops: { type: 'array', items: { $ref: '#/components/schemas/Drop' } },++ },++ },++ Price: { type: 'object', properties: { slug: { type: 'string' }, size: { type: 'string', nullable: true }, condition: { type: 'string', nullable: true }, price: { type: 'number', nullable: true }, currency: { type: 'string' }, checked_at: { type: 'string', nullable: true }, offers: { type: 'integer' } } },++ Drop: {++ type: 'object',++ properties: {++ id: { type: 'string' }, slug: { type: 'string' }, brand: { type: 'string' }, model: { type: 'string' }, colourway: { type: 'string', nullable: true },++ release_at: { type: 'string', format: 'date-time' }, price: { type: 'number', nullable: true }, currency: { type: 'string' },++ sizes: { type: 'array', items: { type: 'string' } }, source_name: { type: 'string' }, source_url: { type: 'string', format: 'uri' }, fetched_at: { type: 'string' },++ },++ },++ Watch: {++ type: 'object',++ properties: {++ id: { type: 'string' }, kind: { type: 'string', enum: ['price', 'link'] }, slug: { type: 'string', nullable: true }, size: { type: 'string', nullable: true },++ condition: { type: 'string', nullable: true }, threshold: { type: 'number', nullable: true }, currency: { type: 'string' },++ source_url: { type: 'string', nullable: true }, remote_id: { type: 'string', nullable: true }, status: { type: 'string' }, note: { type: 'string', nullable: true }, created_at: { type: 'string' },++ },++ },++ WatchRequest: {++ type: 'object', required: ['slug', 'threshold'],++ properties: {++ slug: { type: 'string', maxLength: 80 }, size: { type: 'string', maxLength: 12 }, condition: { type: 'string', enum: ['new', 'used', 'deadstock'] },++ threshold: { type: 'number', exclusiveMinimum: 0 }, currency: { type: 'string', default: 'USD' },++ },++ },++ },++ },++ };++}++++module.exports = { openapi };nnew file mode 100644@@@ -0,0 +1,233 @@++'use strict';++++/**++ * Shoes' server-rendered pages. They work without JavaScript: forms are plain POSTs and every link is a real link.++ * Public pages are served public; a page drawn for a signed-in person is private, no-store.++ *++ * / the room's front door (sc.room: hero, proof, rails, scenes, explore, dev, faq)++ * /shoes search and facets++ * /shoe/:slug one model: offers, own snapshots, size curve, cited facts, the watch form++ * /shoe/:slug/watch POST: a price watch through OpenVibe.Watch++ * /track a pasted listing link, through Watch's own fetch rules++ * /drops the official release calendar, with "remind me" on Today++ * /drops/:id/remind POST: put the release on the person's Today agenda++ * /watches a signed-in person's watches++ * /developers the API++ * /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt++ */++const express = require('express');++const { asyncRouter } = require('./router');++const { sameOrigin } = require('./principal');++const { send } = require('../render/layout');++const views = require('../views');++const discovery = require('./discovery');++++const SORTS = new Set(['recent', 'drop', 'price', 'price_desc', 'popular', 'name']);++const PER_PAGE = 24;++++function createPageRoutes(ctx) {++ const { config, catalogue, upstreams } = ctx;++ const r = asyncRouter();++ r.use(express.urlencoded({ extended: false, limit: '64kb' }));++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });++ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;++ const base = () => String(config.baseUrl).replace(/\/+$/, '');++ const noticeOf = (req) => (typeof req.query.notice === 'string' ? req.query.notice.slice(0, 240) : null);++ const guardOrigin = (req, res) => {++ if (req.viewer && req.viewer.kind === 'user' && req.viewer.token && !sameOrigin(req, base())) {++ page(req, res, { title: 'Not from this site', body: views.signIn({ title: 'That came from somewhere else', next: '/' }), active: '/' }, 403);++ return false;++ }++ return true;++ };++++ r.get('/', async (req, res) => {++ const now = Date.now();++ const [facets, dropping, popular, jobs] = await Promise.all([++ catalogue.facets(),++ catalogue.listDrops({ from: new Date(now).toISOString(), to: new Date(now + 7 * 86400_000).toISOString(), limit: 12 }),++ catalogue.listShoes({ sort: 'popular', limit: 8 }),++ catalogue.lastJobRuns(),++ ]);++ const dropsToday = await catalogue.listShoes({ sort: 'drop', limit: 12 });++ let curveModel = null; let curve = null;++ for (const m of popular.slice(0, 6)) {++ const c = await catalogue.curve(m.id);++ if (c.points.length) { curveModel = m; curve = c; break; }++ }++ const sceneDrop = dropping[0] || (await catalogue.listDrops({ from: new Date(now).toISOString(), limit: 1 }))[0] || null;++ const body = views.home({ facets, dropping, dropsToday, popular, curveModel, curve, sceneDrop, jobs, now });++ return page(req, res, {++ title: null,++ description: 'OpenVibe.Shoes: every shoe, every market, the right price. A page per model with every store\'s all-in price, Shoes\' own price history, the size-price curve and a watch for your size.',++ body,++ active: '/',++ index: true,++ jsonLd: discovery.homeJsonLd(config),++ });++ });++++ r.get('/shoes', async (req, res) => {++ const q = typeof req.query.q === 'string' ? req.query.q.trim().slice(0, 80) : '';++ const brand = typeof req.query.brand === 'string' ? req.query.brand.trim().slice(0, 80) : '';++ const size = typeof req.query.size === 'string' ? req.query.size.trim().slice(0, 12) : '';++ const condition = ['new', 'used', 'deadstock'].includes(req.query.condition) ? req.query.condition : '';++ const store = typeof req.query.store === 'string' ? req.query.store.trim().slice(0, 120) : '';++ const sort = SORTS.has(req.query.sort) ? req.query.sort : 'recent';++ const pageNo = Math.max(1, Math.min(200, parseInt(req.query.page, 10) || 1));++ const filters = { q: q || null, brand: brand || null, size: size || null, condition: condition || null, store: store || null };++ const [models, facets, total] = await Promise.all([++ catalogue.listShoes({ ...filters, sort, limit: PER_PAGE, offset: (pageNo - 1) * PER_PAGE }),++ catalogue.facets(),++ catalogue.countShoes(filters),++ ]);++ return page(req, res, {++ title: q ? `Shoes matching “${q}”` : 'Every shoe',++ description: 'Search every shoe in OpenVibe.Shoes by brand, model, size and condition; every price is all-in with its store and the time it was checked.',++ body: views.shoesIndex({ models, facets, query: { ...filters, sort }, page: pageNo, total }),++ active: '/shoes',++ robots: q || brand || size || condition || store ? 'noindex, follow' : 'index, follow',++ });++ });++++ r.get('/shoe/:slug', async (req, res) => {++ const model = await catalogue.modelBySlug(String(req.params.slug || '').slice(0, 80));++ if (!model) return page(req, res, { title: 'Not found', body: views.notFound(), active: '/shoes' }, 404);++ const [offers, history, curve, facts, releases] = await Promise.all([++ catalogue.offersOf(model.id),++ catalogue.history(model.id, { days: 90 }),++ catalogue.curve(model.id),++ catalogue.factsOf(model.id),++ catalogue.dropsForModel(model),++ ]);++ const low = offers.map((o) => o.all_in).filter((p) => p != null).sort((a, b) => a - b)[0];++ return page(req, res, {++ title: `${model.brand} ${model.model}${low != null ? `: ${views.money(low)} all-in` : ''}`,++ description: `${model.brand} ${model.model}: every store's all-in price, Shoes' own price history, the size-price curve${facts.length ? ' and cited specs' : ''}.`,++ body: views.shoe({ model, offers, history, curve, facts, drops: releases, signedIn: Boolean(signedIn(req)) }),++ active: '/shoes',++ index: true,++ jsonLd: [discovery.productJsonLd(config, model, offers, low)],++ });++ });++++ r.post('/shoe/:slug/watch', async (req, res) => {++ if (!signedIn(req)) return res.redirect(302, `/auth/login?next=${encodeURIComponent(`/shoe/${req.params.slug}`)}`);++ if (!guardOrigin(req, res)) return;++ const model = await catalogue.modelBySlug(String(req.params.slug || '').slice(0, 80));++ if (!model) return page(req, res, { title: 'Not found', body: views.notFound(), active: '/shoes' }, 404);++ const threshold = Number(req.body.threshold);++ if (!Number.isFinite(threshold) || threshold <= 0) {++ const [offers, history, curve, facts, releases] = await Promise.all([catalogue.offersOf(model.id), catalogue.history(model.id), catalogue.curve(model.id), catalogue.factsOf(model.id), catalogue.dropsForModel(model)]);++ return page(req, res, { title: 'A price is needed', body: views.shoe({ model, offers, history, curve, facts, drops: releases, signedIn: true }), active: '/shoes' }, 400);++ }++ const size = String(req.body.size || '').slice(0, 12) || null;++ const condition = ['new', 'used', 'deadstock'].includes(req.body.condition) ? req.body.condition : null;++ const qs = new URLSearchParams({ size: size || '', condition: condition || '' });++ const feedUrl = `${base()}/api/v1/shoes/${encodeURIComponent(model.slug)}/price?${qs.toString()}`;++ const body = {++ name: `Shoes: ${model.brand} ${model.model}${size ? ` size ${size}` : ''} below $${threshold}`.slice(0, 120),++ source: { kind: 'api', url: feedUrl },++ cadence: { every_sec: config.watch.everySec },++ extraction: { kind: 'json', value_path: 'price' },++ condition: { op: 'lt', value: threshold },++ action: [{ kind: 'notification', category: 'service', priority: 'normal' }],++ };++ const out = await upstreams.createWatch(body, { subject: req.viewer.subject });++ await catalogue.createWatch({++ subject: req.viewer.subject, kind: 'price', modelId: model.id, slug: model.slug, size, condition,++ threshold, currency: 'USD', remoteId: out.ok && out.watch ? out.watch.id : null,++ status: out.ok ? 'active' : 'not_connected', note: out.ok ? null : out.detail,++ });++ const notice = out.ok ? 'Your watch is in OpenVibe.Watch. It will tell you when the all-in price drops below your number.' : `Saved on Shoes, but not connected to OpenVibe.Watch yet: ${out.detail}`;++ return res.redirect(302, `/watches?notice=${encodeURIComponent(notice)}`);++ });++++ r.get('/track', (req, res) => page(req, res, {++ title: 'Track a listing link',++ description: 'Paste a store, eBay or Marketplace listing and OpenVibe.Watch keeps it, through its own fetch rules.',++ body: views.track({ signedIn: Boolean(signedIn(req)), notice: noticeOf(req) }),++ active: '/watches',++ robots: 'noindex, follow',++ }));++++ r.post('/track', async (req, res) => {++ if (!signedIn(req)) return res.redirect(302, '/auth/login?next=%2Ftrack');++ if (!guardOrigin(req, res)) return;++ const checkedUrl = await upstreams.checkPastedUrl(req.body.url);++ if (!checkedUrl.ok) return page(req, res, { title: 'Track a listing link', body: views.track({ signedIn: true, notice: checkedUrl.detail, url: req.body.url }), active: '/watches', robots: 'noindex, follow' }, 400);++ const body = {++ name: `Shoes: a pasted listing (${checkedUrl.host})`.slice(0, 120),++ source: { kind: 'http', url: checkedUrl.url, method: 'GET' },++ cadence: { every_sec: config.watch.everySec },++ extraction: { kind: 'text' },++ condition: { op: 'changed' },++ action: [{ kind: 'notification', category: 'service', priority: 'normal' }],++ };++ const out = await upstreams.createWatch(body, { subject: req.viewer.subject });++ await catalogue.createWatch({++ subject: req.viewer.subject, kind: 'link', sourceUrl: checkedUrl.url,++ remoteId: out.ok && out.watch ? out.watch.id : null,++ status: out.ok ? 'active' : 'not_connected', note: out.ok ? null : out.detail,++ });++ const notice = out.ok ? 'The listing is a Watch in OpenVibe.Watch. It applies its own fetch rules to every check.' : `Saved on Shoes, but not connected to OpenVibe.Watch yet: ${out.detail}`;++ return res.redirect(302, `/watches?notice=${encodeURIComponent(notice)}`);++ });++++ r.get('/drops', async (req, res) => {++ const now = Date.now();++ const list = await catalogue.listDrops({ from: new Date(now - 3600_000).toISOString(), limit: 120 });++ return page(req, res, {++ title: 'The drops calendar',++ description: 'Official shoe release dates, each cited to the brand\'s or store\'s own public release page, with a reminder on OpenVibe.Today. Raffle entries are never automated.',++ body: views.drops({ drops: list, signedIn: Boolean(signedIn(req)), notice: noticeOf(req) }),++ active: '/drops',++ index: true,++ });++ });++++ r.post('/drops/:id/remind', async (req, res) => {++ if (!signedIn(req)) return res.redirect(302, `/auth/login?next=${encodeURIComponent('/drops')}`);++ if (!guardOrigin(req, res)) return;++ const drop = await catalogue.dropById(String(req.params.id || ''));++ if (!drop) return page(req, res, { title: 'Not found', body: views.notFound(), active: '/drops' }, 404);++ if (Date.parse(drop.release_at) < Date.now() - 5 * 60_000) {++ return res.redirect(302, `/drops?notice=${encodeURIComponent('That release has already happened, so there is nothing to remind you about.')}`);++ }++ const event = {++ kind: 'event',++ title: `Drop: ${drop.brand} ${drop.model}`.slice(0, 120),++ description: `${drop.colourway ? `${drop.colourway}. ` : ''}Official release: ${drop.source_url}${drop.price != null ? ` · ${drop.price} ${drop.currency}` : ''}. Listed by ${drop.source_name}; read ${drop.fetched_at}. Shoes never enters raffles.`.slice(0, 1000),++ starts_at: drop.release_at,++ tz: 'UTC',++ visibility: 'public',++ };++ const out = await upstreams.createTodayEvent(event, { token: req.viewer.token });++ const notice = out.ok ? 'The drop is on your OpenVibe.Today agenda. Today carries the reminder; Shoes never enters the raffle.' : `Could not add it to Today: ${out.detail}`;++ return res.redirect(302, `/drops?notice=${encodeURIComponent(notice)}`);++ });++++ r.get('/watches', async (req, res) => {++ const list = signedIn(req) ? await catalogue.listWatches(req.viewer.subject) : [];++ return page(req, res, {++ title: 'Your watches',++ description: 'The price watches and pasted listing links that OpenVibe.Watch carries for you.',++ body: views.watches({ watches: list, signedIn: Boolean(signedIn(req)), notice: noticeOf(req) }),++ active: '/watches',++ robots: 'noindex, nofollow',++ });++ });++++ r.get('/developers', (req, res) => page(req, res, {++ title: 'The Shoes API',++ description: 'The open JSON API for shoes, offers, price history, size curves, drops and watches, with an OpenAPI document.',++ body: views.developers({ baseUrl: base() }),++ active: '/developers',++ index: true,++ }));++++ r.use(discovery.createDiscoveryRoutes(ctx));++ return r;++}++++module.exports = { createPageRoutes };nnew file mode 100644@@@ -0,0 +1,113 @@++'use strict';++++/**++ * Who is calling /api/v1 — req.principal:++ *++ * { kind: 'user', subject: 'usr_…', username, role, token, viaSession }++ * a person: their Network user token as a Bearer, or this site's session cookie (shoes_at).++ * { kind: 'app', requester: 'app:…' | 'agent:…' | 'service:…', project, claims }++ * a Network app, agent or service token for audience open.shoes. No Shoes route needs one; the shape is++ * kept so a future capability route can be added in one place.++ * { kind: 'anonymous' }++ *++ * A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the++ * session or to anonymous. A write made with the session cookie must come from this site (Sec-Fetch-Site++ * same-origin, or an Origin equal to ours): another site cannot make a signed-in visitor vote, caption or report.++ */++const { serviceAuth, http, ids } = require('openvibe-contracts');++++const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++++// Shoes' routes are a person acting for themself and a public read. No capability.++const CAPABILITIES = [];++++function decodePayload(token) {++ const parts = String(token || '').split('.');++ if (parts.length !== 3) return null;++ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }++}++++function roleClaim(claims) {++ return typeof claims.role === 'string' && claims.role ? claims.role.slice(0, 32) : 'user';++}++function usernameClaim(claims) {++ return typeof claims.username === 'string' ? claims.username.slice(0, 64) : null;++}++++function requesterOfSub(sub) {++ if (sub.startsWith('svc:')) return `service:${sub.slice(4)}`;++ return sub;++}++++/** Same-origin for a signed-in write: the browser's fetch metadata, else the Origin header. */++function sameOrigin(req, baseUrl) {++ const site = String(req.get('sec-fetch-site') || '');++ if (site) return site === 'same-origin';++ const origin = req.get('origin');++ if (origin) return origin === baseUrl || origin === `${req.protocol}://${req.get('host')}`;++ return false;++}++++function createPrincipal({ config, keys }) {++ async function resolve(req) {++ const header = String(req.headers.authorization || '');++ if (header.startsWith('Bearer ')) {++ const token = header.slice(7).trim();++ const payload = decodePayload(token);++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {++ const publicKey = await keys.pemForToken(token);++ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });++ if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };++ const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;++ return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, claims: r.claims } };++ }++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience });++ if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };++ if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };++ if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };++ return { principal: { kind: 'user', subject: v.claims.subject_id, requester: `user:${v.claims.subject_id}`, project: null, role: roleClaim(v.claims), username: usernameClaim(v.claims), token, viaSession: false } };++ }++ const viewer = req.viewer;++ if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {++ return { principal: { kind: 'user', subject: viewer.subject, requester: `user:${viewer.subject}`, project: null, role: viewer.role || 'user', username: viewer.username || null, token: viewer.token, viaSession: true } };++ }++ return { principal: { kind: 'anonymous' } };++ }++++ async function middleware(req, res, next) {++ const r = await resolve(req);++ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });++ req.principal = r.principal;++ return next();++ }++++ function require_(cap) {++ if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.Shoes's capabilities`);++ return function guard(req, res, next) {++ if (req.principal.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at open.shoes, or send a person\'s Network token as a Bearer.', ctx: req.ov });++ if (req.principal.kind === 'user') {++ if (req.principal.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'shoes.cross_site', { detail: 'A signed-in request that changes something must come from open.shoes itself.', ctx: req.ov });++ return next();++ }++ return http.sendProblem(res, 403, 'shoes.person_required', { detail: 'This route is for a person, not an app.', ctx: req.ov });++ };++ }++++ /** A person (session cookie or Bearer); a write made with the session must come from this site. */++ function requirePerson(req, res, next) {++ if (req.principal.kind === 'user') {++ if (req.principal.viaSession && req.method !== 'GET' && req.method !== 'HEAD' && !sameOrigin(req, config.baseUrl)) {++ return http.sendProblem(res, 403, 'shoes.cross_site', { detail: 'A signed-in request that changes something must come from open.shoes itself.', ctx: req.ov });++ }++ return next();++ }++ if (req.principal.kind === 'app') return http.sendProblem(res, 403, 'shoes.person_required', { detail: 'This route is for a person, not an app or a service.', ctx: req.ov });++ return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at open.shoes, or send a person\'s Network token as a Bearer.', ctx: req.ov });++ }++++ return { middleware, resolve, requireCapability: require_, requirePerson };++}++++module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };nnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++++/**++ * express.Router() whose get/post/patch/delete handlers may be async: a rejected promise goes to next(err)++ * (the app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.++ */++const express = require('express');++++function asyncRouter() {++ const r = express.Router();++ for (const method of ['get', 'post', 'patch', 'delete', 'put', 'all']) {++ const orig = r[method].bind(r);++ r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {++ try {++ const out = h(req, res, next);++ if (out && typeof out.catch === 'function') out.catch(next);++ } catch (err) { next(err); }++ })));++ }++ return r;++}++++module.exports = { asyncRouter };nnew file mode 100644@@@ -0,0 +1,27 @@++'use strict';++++/**++ * Account export and deletion → Shoes (ADR-033; openvibe-sdk/account-data). The only rows a person owns here are++ * their watches. A model, an offer, a snapshot, a drop or a cited fact is the network's public record, not a++ * person's, so it is not exported or erased with an account; a watch is deleted outright.++ *++ * The database stores bare usr_ ids in the subject column. A watch is a threshold, a size and a public URL; it is++ * never a secret.++ */++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++++const TABLES = [++ { table: 'shoe_watches', subject: 'subject', value: (usr) => usr, file: 'watches.json', erase: 'delete' },++];++++function create({ db, log = console } = {}) {++ return createAccountData({++ db,++ service: 'shoes',++ tables: TABLES,++ note: 'Models, offers, prices and drops are the network\'s public shoe record; only your watches are here.',++ log,++ });++}++++module.exports = { create, TABLES, TOPICS };nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++++/**++ * OpenVibe.Shoes — process entry. `node server/index.js`++ * Listens on PORT (5180) behind nginx (deploy/).++ */++const { createApp } = require('./app');++const { gracefulStop } = require('openvibe-sdk/service');++const { startTelemetryRetention } = require('openvibe-sdk/telemetry');++const { startSubscriptions } = require('openvibe-sdk/account-data');++++/**++ * The process stop (openvibe-sdk/service): the HTTP drain runs, then the JWKS refresher stops, the jobs stop, the++ * store closes and the Events subscriptions stop. Exported so a test can inject `exit` and `signals: false`.++ */++function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {++ return gracefulStop({++ name: 'OpenVibe.Shoes', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,++ close: [++ () => { for (const t of timers) clearInterval(t); },++ () => ctx.keys.client.stop(),++ () => ctx.jobs.stop(),++ async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },++ () => ctx.s.close(),++ ...extra,++ ],++ });++}++++async function start() {++ const { app, ctx } = await createApp();++ const { config } = ctx;++++ const server = app.listen(config.port, config.host, () => {++ console.log(`[OpenVibe.Shoes] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.s.db.store})`);++ // Warm the Network signing key immediately: a service nobody has signed in to yet must not read degraded++ // only because the first JWKS fetch had not happened.++ ctx.keys.ensure().catch(() => {});++ });++ server.keepAliveTimeout = 65_000;++ ctx.retention = startTelemetryRetention({ db: ctx.s.db, log: ctx.log || console, olderThanDays: 14 });++ ctx.telemetryTick = setInterval(() => { ctx.telemetry.flush().catch(() => {}); }, 60_000);++ ctx.telemetryTick.unref();++ ctx.keys.client.start();++++ // Jobs. Each refuses to start under SHOES_DRILL; tests call their runOnce().++ ctx.jobs.start();++++ // The two account topics (export_requested, deleted) at OpenVibe.Events: idempotent; off without the URL, the++ // delivery secret and the client secret. The consumer itself is mounted in server/app.js.++ const subs = config.drill ? null : startSubscriptions({++ eventsUrl: config.events.url,++ endpoint: config.events.endpoint || `http://127.0.0.1:${config.port}/internal/events`,++ secret: config.events.secrets[0] || '',++ networkInternalUrl: config.networkInternalUrl,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ log: ctx.log || console,++ });++ const extra = [() => { if (subs) subs.stop(); }];++ createLifecycle({ server, ctx, extra });++ return { server, ctx };++}++++if (require.main === module) {++ start().catch((err) => { console.error('[OpenVibe.Shoes] failed to start:', err); process.exit(1); });++}++++module.exports = { start, createLifecycle };nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++++/**++ * The drops job: a brand's or a store's own public release page (and any RSS feeds a host configures) is read for++ * releases it publishes, and each one is written with the page it came from and the time it was read. No date is++ * invented; a release without a date is not imported. Raffle entries are never automated.++ *++ * Off under SHOES_DRILL; tests and scripts call runOnce().++ */++function createDropsJob({ config, catalogue, upstreams, log = console }) {++ let timer = null;++ let running = false;++++ async function runOnce() {++ const runId = await catalogue.startJobRun({++ job: 'drops',++ sourceName: config.sources.dropsSourceName,++ sourceUrl: config.sources.dropsUrl,++ });++ const counts = { sources: 0, releases: 0, drops: 0, skipped: 0 };++ try {++ const sources = [{ url: config.sources.dropsUrl, name: config.sources.dropsSourceName, type: 'launch' }]++ .concat(config.sources.dropFeeds.map((f) => ({ url: f.url, name: f.name || f.url, type: f.type === 'rss' ? 'rss' : 'launch' })));++ for (const source of sources) {++ let releases = [];++ try {++ releases = await upstreams.releaseCalendar(source);++ } catch (err) {++ log.warn(`[OpenVibe.Shoes] drops source ${source.url} failed: ${err.message}`);++ continue;++ }++ counts.sources++;++ counts.releases += releases.length;++ for (const release of releases) {++ const row = await catalogue.upsertDrop(release);++ if (row) counts.drops++;++ else counts.skipped++;++ }++ }++ await catalogue.finishJobRun(runId, { ok: true, counts });++ log.log(`[OpenVibe.Shoes] drops: ${counts.drops} releases from ${counts.sources} source(s)`);++ return counts;++ } catch (err) {++ await catalogue.finishJobRun(runId, { ok: false, counts, error: err.message });++ throw err;++ }++ }++++ function start() {++ if (config.drill || timer) return;++ const tick = () => {++ if (running) return;++ running = true;++ runOnce().catch((err) => log.warn('[OpenVibe.Shoes] drops job failed:', err.message)).finally(() => { running = false; });++ };++ timer = setInterval(tick, config.jobs.dropsIntervalMs);++ timer.unref();++ setTimeout(tick, 12_000).unref();++ }++++ function stop() {++ if (timer) clearInterval(timer);++ timer = null;++ }++++ return { name: 'drops', runOnce, start, stop };++}++++module.exports = { createDropsJob };nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++++/**++ * The eBay job: when the host sets an eBay developer key pair, Shoes reads the official Browse API's shoe item++ * summaries (never a scrape) and writes them into the same catalogue as any other offer. Without the key pair it++ * is off and says so plainly; it is never an error.++ *++ * Off under SHOES_DRILL; tests and scripts call runOnce().++ */++const parse = require('../domain/parse');++++function createEbayJob({ config, catalogue, upstreams, log = console }) {++ let timer = null;++ let running = false;++++ async function runOnce() {++ if (!upstreams.ebayConfigured()) {++ return { configured: false, offers: 0, note: 'eBay is off (SHOES_EBAY_CLIENT_ID and SHOES_EBAY_CLIENT_SECRET are not set).' };++ }++ const runId = await catalogue.startJobRun({++ job: 'ebay',++ sourceName: 'eBay Browse API',++ sourceUrl: `${config.sources.ebay.apiUrl}/buy/browse/v1/item_summary/search`,++ });++ const counts = { items: 0, offers: 0, skipped: 0 };++ try {++ const out = await upstreams.ebayShoes();++ counts.items = out.items.length;++ for (const item of out.items) {++ if (!parse.isShoe(item.title)) { counts.skipped++; continue; }++ const row = await catalogue.upsertOffer({++ ...item,++ modelName: item.title,++ colourway: parse.colourwayOf(item.title),++ raw: { itemId: item.sourceId, condition: item.condition || null },++ });++ if (row) counts.offers++;++ else counts.skipped++;++ }++ await catalogue.finishJobRun(runId, { ok: true, counts });++ log.log(`[OpenVibe.Shoes] ebay: ${counts.offers} offers from ${counts.items} items`);++ return { configured: true, ...counts };++ } catch (err) {++ await catalogue.finishJobRun(runId, { ok: false, counts, error: err.message });++ throw err;++ }++ }++++ function start() {++ if (config.drill || timer || !upstreams.ebayConfigured()) return;++ const tick = () => {++ if (running) return;++ running = true;++ runOnce().catch((err) => log.warn('[OpenVibe.Shoes] ebay job failed:', err.message)).finally(() => { running = false; });++ };++ timer = setInterval(tick, config.jobs.ebayIntervalMs);++ timer.unref();++ setTimeout(tick, 20_000).unref();++ }++++ function stop() {++ if (timer) clearInterval(timer);++ timer = null;++ }++++ return { name: 'ebay', runOnce, start, stop };++}++++module.exports = { createEbayJob };nnew file mode 100644@@@ -0,0 +1,29 @@++'use strict';++++/**++ * The jobs, together: offers (OpenVibe.Deals), drops (official release pages) and eBay (official Browse API, only++ * with a key pair). Each refuses to start under SHOES_DRILL, where tests and a restore never touch a source.++ */++const { createOffersJob } = require('./offers');++const { createDropsJob } = require('./drops');++const { createEbayJob } = require('./ebay');++++function createJobs({ config, catalogue, upstreams, log = console }) {++ const offers = createOffersJob({ config, catalogue, upstreams, log });++ const drops = createDropsJob({ config, catalogue, upstreams, log });++ const ebay = createEbayJob({ config, catalogue, upstreams, log });++ const all = [offers, drops, ebay];++++ function start() {++ if (config.drill) return;++ for (const job of all) job.start();++ }++++ function stop() {++ for (const job of all) job.stop();++ }++++ return { offers, drops, ebay, all, start, stop };++}++++module.exports = { createJobs };nnew file mode 100644@@@ -0,0 +1,113 @@++'use strict';++++/**++ * The offers job: OpenVibe.Deals already aggregates stores' offers, so Shoes reads its public API (the same++ * newest-offer pages any person can read), keeps the footwear, and writes each one into Shoes' own catalogue with++ * its price, shipping, condition, size when the source names one, the time the source checked it and a stale flag.++ *++ * The brand and the clean model name come from the offer's product on Deals (its public product API). The++ * classifier (server/domain/parse.js) decides what is footwear; a title that names no shoe is never imported.++ *++ * Off under SHOES_DRILL; tests and scripts call runOnce().++ */++const parse = require('../domain/parse');++++function createOffersJob({ config, catalogue, upstreams, log = console }) {++ let timer = null;++ let running = false;++++ function shoeCandidate(offer) {++ const productName = offer && offer.product && offer.product.name ? offer.product.name : '';++ return parse.isShoe(offer && offer.title, productName);++ }++++ async function runOnce({ pages = config.sources.offersPages, maxProductLookups = config.sources.productLookups } = {}) {++ const runId = await catalogue.startJobRun({++ job: 'offers',++ sourceName: 'OpenVibe.Deals',++ sourceUrl: `${config.sources.dealsUrl}/api/v1/offers`,++ });++ const counts = { pages: 0, seen: 0, candidates: 0, product_lookups: 0, offers: 0, skipped: 0 };++ const brands = new Map();++ try {++ const total = Math.max(1, Math.min(200, Number(pages) || 1));++ for (let page = 1; page <= total; page++) {++ const offers = await upstreams.dealsOffers(page);++ if (!offers.length) break;++ counts.pages++;++ counts.seen += offers.length;++ for (const offer of offers) {++ if (!shoeCandidate(offer)) continue;++ counts.candidates++;++ const observation = offer.latest_observation || {};++ const product = offer.product || {};++ let brand = null;++ if (product.slug && counts.product_lookups < maxProductLookups) {++ if (brands.has(product.slug)) brand = brands.get(product.slug);++ else {++ const p = await upstreams.dealsProduct(product.slug);++ counts.product_lookups++;++ brand = p && p.brand ? p.brand : null;++ brands.set(product.slug, brand);++ }++ }++ const modelName = product.name || offer.title;++ const checkedAt = observation.observed_at || observation.recorded_at || new Date().toISOString();++ const row = await catalogue.upsertOffer({++ source: 'deals',++ sourceId: offer.id,++ title: offer.title,++ modelName,++ brand,++ url: offer.link || offer.url,++ store: offer.store && offer.store.domain,++ storeName: offer.store && offer.store.name,++ price: observation.price,++ listPrice: observation.list_price,++ currency: observation.currency || 'USD',++ shipping: observation.shipping,++ condition: observation.condition,++ size: parse.sizeOf(offer.title),++ colourway: parse.colourwayOf(offer.title),++ checkedAt,++ raw: {++ id: offer.id, slug: offer.slug, product: product.slug || null,++ observed_at: observation.observed_at || null,++ },++ });++ if (row) counts.offers++;++ else counts.skipped++;++ if (config.sources.requestGapMs) await upstreams.sleep(config.sources.requestGapMs / 2);++ }++ if (page < total && config.sources.requestGapMs) await upstreams.sleep(config.sources.requestGapMs);++ }++ await catalogue.finishJobRun(runId, { ok: true, counts });++ log.log(`[OpenVibe.Shoes] offers: ${counts.offers} offers from ${counts.candidates} shoe candidates over ${counts.pages} pages`);++ return counts;++ } catch (err) {++ await catalogue.finishJobRun(runId, { ok: false, counts, error: err.message });++ throw err;++ }++ }++++ function start() {++ if (config.drill || timer) return;++ const tick = () => {++ if (running) return;++ running = true;++ runOnce().catch((err) => log.warn('[OpenVibe.Shoes] offers job failed:', err.message)).finally(() => { running = false; });++ };++ timer = setInterval(tick, config.jobs.offersIntervalMs);++ timer.unref();++ setTimeout(tick, 8_000).unref();++ }++++ function stop() {++ if (timer) clearInterval(timer);++ timer = null;++ }++++ return { name: 'offers', runOnce, start, stop, shoeCandidate };++}++++module.exports = { createOffersJob };nnew file mode 100644@@@ -0,0 +1,39 @@++'use strict';++++/**++ * Truthful readiness for GET /api/ready (openvibe-shared/ready): the database is required; the Network signing key,++ * the OAuth client and Valkey are optional. The key store is warmed right after listen, so a service nobody has++ * signed in to yet is not degraded only because the first fetch had not happened.++ */++const { jwksStatus } = require('openvibe-sdk/auth');++const { createReadiness } = require('openvibe-shared/ready');++++function createServiceReadiness({ s, config, valkey = null, release = null }) {++ return createReadiness({++ service: 'shoes',++ release,++ checks: [++ {++ name: 'db', required: true,++ check: async () => {++ const r = await s.db.ready();++ return r.ok ? { ok: true, detail: r.detail } : r.error;++ },++ },++ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-caller limits count in this process only' }) },++ {++ name: 'network_jwks', required: false,++ check: () => {++ const statuses = jwksStatus();++ if (!statuses.length) return 'no JWKS clients (misconfigured)';++ const first = statuses[0];++ if (!first.ready) return 'Network signing key not loaded yet: sign-in and the API are unavailable';++ return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };++ },++ },++ { name: 'oauth_client', required: false, check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete') },++ ],++ });++}++++module.exports = { createServiceReadiness };nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++++/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }++const raw = (s) => new Raw(s);++function html(strings, ...values) {++ let out = strings[0];++ values.forEach((v, i) => { out += render(v) + strings[i + 1]; });++ return raw(out);++}++function render(v) {++ if (v == null || v === false) return '';++ if (v instanceof Raw) return v.s;++ if (Array.isArray(v)) return v.map(render).join('');++ return esc(v);++}++++const code = (s) => html`<code>${s}</code>`;++const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');++++function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {++ if (!rows.length) return html`<p class="muted">${empty}</p>`;++ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;++}++++const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;++const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;++++module.exports = { esc, html, raw, Raw, render, code, time, table, notice, badge };nnew file mode 100644@@@ -0,0 +1,112 @@++'use strict';++++/**++ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.++ * The room's accent comes from openvibe-shared/rooms (`shoes`, #FF7E76 dark / #B02A2D light) and the navigation's++ * name is `Shoes`; the domain is always written `open.shoes`.++ */++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const appIcon = require('openvibe-shared/app-icon');++const shell = require('openvibe-shared/shell');++const rooms = require('openvibe-shared/rooms');++const ovServe = require('openvibe-shared/serve');++const cache = require('openvibe-shared/cache-policy');++const { esc } = require('./html');++++const NETWORK_URL = 'https://openvibe.network';++const SITE_NAME = 'OpenVibe.Shoes';++const MENU_NAME = 'Shoes';++const TAGLINE = 'Every shoe, every market, the right price.';++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');++const NAV = [++ { label: 'Home', href: '/', icon: 'fa-shoe-prints' },++ { label: 'Shoes', href: '/shoes', icon: 'fa-magnifying-glass' },++ { label: 'Drops', href: '/drops', icon: 'fa-calendar-days' },++ { label: 'Watches', href: '/watches', icon: 'fa-bell' },++ { label: 'Developers', href: '/developers', icon: 'fa-code', more: true },++];++const API_BASE = 'https://open.shoes';++++const hashes = new Map();++function assetVersion(rel) {++ if (hashes.has(rel)) return hashes.get(rel);++ let v = 'dev';++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }++ hashes.set(rel, v);++ return v;++}++const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;++++let RELEASE = 'dev';++function setRelease(id) { if (id) RELEASE = String(id); }++++/**++ * o: title, description, body (trusted HTML), viewer, config, path, active, robots, canonical, jsonLd++ */++function layout(o) {++ const config = o.config || { baseUrl: API_BASE };++ const signedIn = o.viewer && o.viewer.kind === 'user';++ const pathNow = o.path || '/';++ const active = o.active || pathNow;++ const canonical = o.canonical === undefined ? `${String(config.baseUrl).replace(/\/+$/, '')}${pathNow.split('?')[0]}` : o.canonical;++ const navbar = {++ service: 'shoes',++ apiBase: NETWORK_URL,++ links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && pathNow.startsWith(`${l.href}/`)) })),++ appBar: [++ { label: 'Shoes', href: '/shoes', icon: 'fa-magnifying-glass' },++ { label: 'Drops', href: '/drops', icon: 'fa-calendar-days' },++ { label: 'Watches', href: '/watches', icon: 'fa-bell' },++ ],++ silentLogin: `${String(config.baseUrl).replace(/\/+$/, '')}/auth/login?silent=1&next={url}`,++ sessionUrl: '/auth/me',++ loginUrl: '/auth/login?next={path}',++ logoutUrl: '/auth/logout?next={path}',++ notificationsRealtime: true,++ history: { type: 'page', title: o.title || SITE_NAME },++ };++ const footer = { service: 'shoes', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };++ return shell.page({++ name: SITE_NAME,++ lang: 'en',++ title: o.title || `${SITE_NAME}: ${TAGLINE}`,++ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',++ siteName: SITE_NAME,++ description: o.description || `${SITE_NAME} — ${TAGLINE} Every price is all-in, with its store, the time it was checked and a stale flag.`,++ canonical,++ robots: o.robots || (o.index ? 'index, follow' : 'noindex, follow'),++ jsonLd: (o.jsonLd || []).filter(Boolean),++ home: '/',++ navLinks: NAV,++ navbar,++ footer,++ head: `${appIcon.headTags({ site: 'shoes' })}++<link rel="stylesheet" href="${esc(ovServe.url('kit.css'))}">++<link rel="stylesheet" href="${esc(ovServe.url('room.css'))}">++<link rel="stylesheet" href="${esc(ovServe.url('showcase.css'))}">++<link rel="stylesheet" href="${asset('css/shoes.css')}">++<script src="${esc(ovServe.url('kit-web.js'))}" defer></script>++<script src="${esc(ovServe.url('motion.js'))}" defer></script>++<meta name="ov-boost" content="shoes@${esc(RELEASE)}">`,++ body: `<a class="skip" href="#main">Skip to content</a>++<div id="navbar-mount"></div>++<noscript><div class="account-bar" role="navigation" aria-label="Account">${signedIn++ ? `Signed in as <strong>${esc(o.viewer.displayName || o.viewer.username || 'you')}</strong>`++ : '<a href="/auth/login">Sign in with OpenVibe</a>'}</div></noscript>++<main id="main" class="sh ov-room" style="${rooms.style('shoes')}">${o.body}</main>++<script>window.__OV_PAGE = ${JSON.stringify({ navbar, footer }).replace(/</g, '\\u003c')};++document.addEventListener('DOMContentLoaded', function () { try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ } });</script>`,++ });++}++++/** Send a page; a page rendered for a signed-in person is private and never cached. */++function send(res, status, o) {++ const personal = o.viewer && o.viewer.kind === 'user';++ if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ public: true, maxAge: 60 })));++ res.set('Vary', 'Cookie');++ res.status(status).type('html').send(layout(o));++}++++module.exports = { layout, send, renderPage: layout, asset, assetVersion, setRelease, SITE_NAME, MENU_NAME, TAGLINE, NETWORK_URL, NAV, API_BASE };nnew file mode 100644@@@ -0,0 +1,240 @@++'use strict';++++/**++ * Everything Shoes reads from the network and from the public web.++ *++ * OpenVibe.Deals the network's own offer aggregator (its public API): the offers, the stores and the++ * products. No key: these are the same reads any person can make.++ * eBay the official Browse API (OAuth2 client credentials) when a key pair is set; off otherwise.++ * a release page a brand's or a store's own public release calendar (an official page, read as a page).++ * OpenVibe.Watch the watch service: a watch for a person (a service token; the person in X-OV-Subject).++ * OpenVibe.Today the drops calendar's reminders (the person's own Network token as a Bearer).++ *++ * A missing key is never an error: the part that needs it is off, with a plain note.++ */++const egress = require('openvibe-shared/egress');++const drops = require('./domain/drops');++const parse = require('./domain/parse');++++class UpstreamError extends Error {++ constructor(message, { status = 502, code = 'shoes.upstream_failed' } = {}) {++ super(message);++ this.status = status;++ this.code = code;++ }++}++++function createUpstreams({ config, fetchImpl = globalThis.fetch, log = console, urlCheck = (u) => egress.assertPublicUrl(u) } = {}) {++ const timeoutMs = config.sources.timeoutMs;++++ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++++ async function request(url, { method = 'GET', headers = {}, body = null, json = false, timeout = timeoutMs } = {}) {++ for (let attempt = 0; ; attempt++) {++ const ctrl = new AbortController();++ const timer = setTimeout(() => ctrl.abort(), timeout);++ try {++ const res = await fetchImpl(url, {++ method,++ headers: { 'user-agent': 'OpenVibe.Shoes/0.1 (+https://open.shoes)', accept: 'application/json, text/html;q=0.9', ...headers },++ body,++ signal: ctrl.signal,++ });++ const text = await res.text();++ if (res.status === 429 && attempt < 2) {++ const retry = Number(res.headers.get('retry-after'));++ await sleep(Math.min(10_000, Math.max(500, (Number.isFinite(retry) && retry > 0 ? retry : 1) * 1000)));++ continue;++ }++ if (!res.ok) throw new UpstreamError(`${method} ${url} answered ${res.status}`, { status: res.status === 429 ? 429 : 502, code: res.status === 429 ? 'shoes.rate_limited' : 'shoes.upstream_failed' });++ if (json) { try { return JSON.parse(text); } catch { throw new UpstreamError(`${url} did not answer JSON`, { status: 502 }); } }++ return text;++ } catch (err) {++ if (err instanceof UpstreamError) throw err;++ throw new UpstreamError(`${method} ${url} failed: ${err && err.message ? err.message : 'network error'}`, { status: 502 });++ } finally {++ clearTimeout(timer);++ }++ }++ }++++ // ── OpenVibe.Deals ──────────────────────────────────────++ const dealsBase = () => config.sources.dealsUrl.replace(/\/+$/, '');++++ /** The newest offers on OpenVibe.Deals (its public API), one page at a time. */++ async function dealsOffers(page = 1) {++ const body = await request(`${dealsBase()}/api/v1/offers?sort=new&page=${encodeURIComponent(page)}`, { json: true });++ return Array.isArray(body && body.offers) ? body.offers : [];++ }++++ /** The offers of one store on OpenVibe.Deals. */++ async function dealsStoreOffers(domain) {++ const body = await request(`${dealsBase()}/api/v1/stores/${encodeURIComponent(domain)}`, { json: true });++ return Array.isArray(body && body.offers) ? body.offers : [];++ }++++ /** One product on OpenVibe.Deals (its own page, with the brand). */++ async function dealsProduct(slug) {++ try {++ const body = await request(`${dealsBase()}/api/v1/products/${encodeURIComponent(slug)}`, { json: true });++ return body && body.product ? body.product : null;++ } catch (err) {++ log.warn('[OpenVibe.Shoes] deals product failed:', err.message);++ return null;++ }++ }++++ // ── eBay Browse API (official) ──────────────────────────++ let ebayToken = null;++ let ebayTokenAt = 0;++++ function ebayConfigured() {++ return Boolean(config.sources.ebay.clientId && config.sources.ebay.clientSecret);++ }++++ async function ebayAccessToken() {++ if (!ebayConfigured()) return null;++ if (ebayToken && Date.now() - ebayTokenAt < 50 * 60_000) return ebayToken;++ const basic = Buffer.from(`${config.sources.ebay.clientId}:${config.sources.ebay.clientSecret}`).toString('base64');++ const body = await request(`${config.sources.ebay.apiUrl}/identity/v1/oauth2/token`, {++ method: 'POST',++ headers: { authorization: `Basic ${basic}`, 'content-type': 'application/x-www-form-urlencoded' },++ body: 'grant_type=client_credentials&scope=https%3A%2F%2Fapi.ebay.com%2Foauth%2Fapi_scope',++ json: true,++ });++ if (!body || !body.access_token) throw new UpstreamError('eBay did not issue a token');++ ebayToken = body.access_token;++ ebayTokenAt = Date.now();++ return ebayToken;++ }++++ /**++ * The official Browse API's item summaries for shoes. Each item keeps eBay's own id, URL, price, shipping,++ * condition and seller; a size is read from the title only when the title names one.++ */++ async function ebayShoes({ q = 'shoes', limit = config.sources.ebay.limit } = {}) {++ if (!ebayConfigured()) return { configured: false, items: [] };++ const token = await ebayAccessToken();++ const params = new URLSearchParams({++ q: String(q).slice(0, 100),++ category_ids: String(config.sources.ebay.categoryId),++ limit: String(Math.max(1, Math.min(200, limit))),++ sort: 'newlyListed',++ });++ const body = await request(`${config.sources.ebay.apiUrl}/buy/browse/v1/item_summary/search?${params.toString()}`, {++ headers: { authorization: `Bearer ${token}`, 'x-ebay-c-marketplace-id': config.sources.ebay.marketplace },++ json: true,++ });++ const items = Array.isArray(body && body.itemSummaries) ? body.itemSummaries : [];++ return {++ configured: true,++ items: items.map((it) => {++ const price = it.price && parse.amountOf(it.price.value);++ const ship = it.shippingOptions && it.shippingOptions[0] && it.shippingOptions[0].shippingCost++ ? parse.amountOf(it.shippingOptions[0].shippingCost.value) : null;++ let store = 'ebay.com';++ try { store = new URL(it.itemWebUrl).hostname.replace(/^www\./, ''); } catch { /* keep ebay.com */ }++ return {++ source: 'ebay',++ sourceId: String(it.itemId || ''),++ title: String(it.title || ''),++ url: String(it.itemWebUrl || ''),++ store,++ storeName: (it.seller && it.seller.username) ? `eBay seller ${it.seller.username}` : 'eBay',++ price,++ shipping: ship,++ condition: parse.conditionOf(it.condition),++ size: parse.sizeOf(it.title),++ currency: (it.price && it.price.currency) || 'USD',++ checkedAt: new Date().toISOString(),++ raw: it,++ };++ }).filter((x) => x.sourceId && x.url && x.price != null),++ };++ }++++ // ── Official release pages ──────────────────────────────++ /** The releases on a brand's or a store's own release calendar. */++ async function releaseCalendar({ url = config.sources.dropsUrl, name = config.sources.dropsSourceName, type = 'launch' } = {}) {++ const html = await request(url, { headers: { accept: 'text/html' } });++ const fetchedAt = new Date().toISOString();++ if (type === 'rss') return drops.parseRss(html, { sourceName: name, sourceUrl: url, fetchedAt });++ return drops.parseLaunchCalendar(html, { sourceName: name, sourceUrl: url, fetchedAt });++ }++++ // ── OpenVibe.Watch ──────────────────────────────────────++ /**++ * Create a watch for a person in OpenVibe.Watch. The service token carries the capability++ * watch.watch.manage; the person is named in X-OV-Subject. Without a token the watch is not created there++ * and the caller says so plainly.++ */++ async function createWatch(body, { subject }) {++ if (!config.watch.token) return { ok: false, code: 'shoes.watch_not_configured', detail: 'OpenVibe.Watch is not connected yet (no service token).' };++ const url = `${config.watch.internalUrl.replace(/\/+$/, '')}/api/v1/watches`;++ try {++ const res = await request(url, {++ method: 'POST',++ headers: {++ 'content-type': 'application/json',++ authorization: `Bearer ${config.watch.token}`,++ 'x-ov-subject': String(subject || ''),++ },++ body: JSON.stringify(body),++ json: true,++ });++ const watch = res && res.watch ? res.watch : res;++ return { ok: true, watch };++ } catch (err) {++ return { ok: false, code: err.code || 'shoes.watch_failed', detail: err.message };++ }++ }++++ // ── OpenVibe.Today ──────────────────────────────────────++ /** Put a drop on a person's Today agenda. The person's own Network token is the Bearer. */++ async function createTodayEvent(body, { token } = {}) {++ if (!token) return { ok: false, code: 'shoes.today_no_token', detail: 'This session has no Network token, so the reminder was not added.' };++ const url = `${config.today.internalUrl.replace(/\/+$/, '')}/api/v1/events`;++ try {++ const res = await request(url, {++ method: 'POST',++ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },++ body: JSON.stringify(body),++ json: true,++ });++ const event = res && res.event ? res.event : res;++ return { ok: true, event };++ } catch (err) {++ return { ok: false, code: err.code || 'shoes.today_failed', detail: err.message };++ }++ }++++ /**++ * The rules for a URL a person pasted: it must be an http(s) address on the public internet (the same rule++ * every OpenVibe service uses for a user-chosen address, openvibe-shared/egress). Facebook Marketplace has no++ * public listings API and its terms forbid reading it directly, so a Marketplace link is accepted as a watch++ * and Watch's own fetch rules decide what it may see.++ */++ async function checkPastedUrl(value) {++ const raw = String(value == null ? '' : value).trim();++ if (!raw) return { ok: false, code: 'shoes.url_required', detail: 'Paste the link to the listing.' };++ if (raw.length > 2000) return { ok: false, code: 'shoes.url_too_long', detail: 'That link is too long.' };++ let url;++ try { url = new URL(raw); } catch { return { ok: false, code: 'shoes.url_invalid', detail: 'That is not a link Shoes can read.' }; }++ if (url.protocol !== 'https:' && url.protocol !== 'http:') {++ return { ok: false, code: 'shoes.url_invalid', detail: 'Only http and https links can be watched.' };++ }++ try {++ await urlCheck(url.toString());++ } catch (err) {++ return { ok: false, code: 'shoes.url_not_public', detail: 'That address is not on the public internet, so it cannot be watched.' };++ }++ return { ok: true, url: url.toString(), host: url.hostname };++ }++++ return {++ request, dealsOffers, sleep, dealsStoreOffers, dealsProduct, ebayShoes, ebayConfigured, releaseCalendar,++ createWatch, createTodayEvent, checkPastedUrl, UpstreamError,++ };++}++++module.exports = { createUpstreams, UpstreamError };nnew file mode 100644@@@ -0,0 +1,398 @@++'use strict';++++/**++ * The Shoes room's pages (plan 2026-10-28 §5.6). Server-rendered and whole without JavaScript: every action is a++ * plain form or link. The room's front door is sc.room (hero, proof, rails, scenes, explore, developers, questions,++ * the rest of OpenVibe); every number on it is read from Shoes' own catalogue or from the source itself.++ *++ * home({ facets, dropping, dropsToday, popular, curveModel, curve, sceneDrop, jobs, brands })++ * shoesIndex({ models, facets, query, page, total })++ * shoe({ model, offers, history, curve, facts, signedIn })++ * drops({ drops, signedIn, notice })++ * watches({ watches, signedIn })++ * developers({ baseUrl })++ * signIn({ title, next })++ * notFound()++ * Ids and classes are stable and start with `sh-`.++ */++const kit = require('openvibe-shared/kit');++const sc = require('openvibe-shared/showcase');++const rooms = require('openvibe-shared/rooms');++++const { esc, icon, card, rail } = kit;++const ROOM = rooms.get('shoes');++const SITE = 'OpenVibe.Shoes';++const money = (n, cur = 'USD') => {++ if (n == null || n === '') return '—';++ const v = Number(n);++ if (!Number.isFinite(v)) return '—';++ const s = v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });++ return cur === 'USD' ? `$${s}` : `${s} ${esc(cur)}`;++};++const pct = (n) => {++ if (n == null || !Number.isFinite(Number(n))) return '—';++ return `${Number(n) > 0 ? '+' : ''}${Number(n).toFixed(1)}%`.replace(/^-/, '−');++};++const dayShort = (iso) => { const t = Date.parse(iso || ''); return Number.isFinite(t) ? new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }) : ''; };++const dayLong = (iso) => { const t = Date.parse(iso || ''); return Number.isFinite(t) ? new Date(t).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }) : '—'; };++const clock = (iso) => { const t = Date.parse(iso || ''); return Number.isFinite(t) ? new Date(t).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' }) : ''; };++const checked = (iso, now = Date.now()) => {++ const t = Date.parse(iso || '');++ if (!Number.isFinite(t)) return 'never checked';++ const mins = Math.max(0, Math.round((now - t) / 60_000));++ if (mins < 2) return 'checked just now';++ if (mins < 90) return `checked ${mins} min ago`;++ const h = Math.round(mins / 60);++ if (h < 48) return `checked ${h} h ago`;++ return `checked ${dayShort(iso)} at ${clock(iso)} UTC`;++};++const stale = (offer) => (offer.stale ? '<span class="sh-stale">stale</span>' : '<span class="sh-fresh">fresh</span>');++const link = (href, text, cls = '') => `<a${cls ? ` class="${esc(cls)}"` : ''} href="${esc(href)}">${esc(text)}</a>`;++const sourceLine = (name, url) => (url ? `<a href="${esc(url)}" rel="noopener">${esc(name)}</a>` : esc(name));++++/** A card for a model: its price from, how many offers, when it was checked. */++function modelCard(m) {++ const price = m.price_min != null ? `from ${money(m.price_min)}` : 'price not known yet';++ return card({++ href: `/shoe/${encodeURIComponent(m.slug)}`,++ title: `${m.brand} ${m.model}`,++ type: 'shoe',++ corner: m.price_min != null ? money(m.price_min) : '',++ badge: m.drop_amount ? `−${money(m.drop_amount)}` : '',++ who: m.brand,++ count: [m.offer_count, m.offer_count === 1 ? 'offer' : 'offers'],++ when: m.checked_at,++ peek: `${price}${m.stale_count ? ` · ${m.stale_count} stale` : ''}`,++ });++}++++/** One row per model, colourway and release moment: a source's variant rows (a style code each) become one release. */++function groupDrops(list = []) {++ const groups = new Map();++ for (const d of list) {++ const key = [d.brand, d.model, d.colourway || '', String(d.release_at).slice(0, 16)].join('|');++ if (!groups.has(key)) {++ groups.set(key, { ...d, sizes: [...(d.sizes || [])], variants: 1 });++ continue;++ }++ const g = groups.get(key);++ g.variants++;++ g.sizes = [...new Set([...g.sizes, ...(d.sizes || [])])].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));++ if (d.price != null && (g.price == null || d.price < g.price)) g.price = d.price;++ }++ return [...groups.values()].sort((a, b) => Date.parse(a.release_at) - Date.parse(b.release_at));++}++++/** A card for a release. */++function dropCard(d) {++ return card({++ href: `/drops#drop-${encodeURIComponent(d.id)}`,++ title: `${d.brand} ${d.model}${d.colourway ? ` · ${d.colourway}` : ''}`,++ type: 'event',++ corner: d.price != null ? money(d.price, d.currency) : '',++ badge: dayShort(d.release_at),++ who: d.source_name,++ peek: `${dayLong(d.release_at)}${d.sizes.length ? ` · ${d.sizes.length} sizes` : ''}`,++ });++}++++/** The small line chart of a model's own snapshots. No made-up points: one snapshot draws one dot. */++function historyChart(points) {++ if (!points.length) return '<p class="sh-muted">No snapshot yet. The first check writes one point; the line grows as Shoes checks again.</p>';++ const w = 640, h = 180, pad = 24;++ const prices = points.map((p) => p.price).filter((p) => p != null);++ const lo = Math.min(...prices), hi = Math.max(...prices);++ const span = hi - lo || 1;++ const x = (i) => pad + (points.length === 1 ? (w - pad * 2) / 2 : (i * (w - pad * 2)) / (points.length - 1));++ const y = (p) => h - pad - ((p - lo) / span) * (h - pad * 2);++ const line = points.map((p, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(p.price).toFixed(1)}`).join(' ');++ const dots = points.map((p, i) => `<circle cx="${x(i).toFixed(1)}" cy="${y(p.price).toFixed(1)}" r="3"><title>${esc(`${p.day}: ${money(p.price)}`)}</title></circle>`).join('');++ return `<figure class="sh-chart"><svg viewBox="0 0 ${w} ${h}" role="img" aria-label="Lowest all-in price by day, ${esc(points[0].day)} to ${esc(points[points.length - 1].day)}">`++ + `<line class="sh-chart-axis" x1="${pad}" y1="${h - pad}" x2="${w - pad}" y2="${h - pad}"></line>`++ + `<line class="sh-chart-axis" x1="${pad}" y1="${pad}" x2="${pad}" y2="${h - pad}"></line>`++ + `<path class="sh-chart-line" d="${line}" fill="none"></path>${dots}</svg>`++ + `<figcaption><span>Lowest all-in price by day</span><b>${money(points[points.length - 1].price)}</b><small>${esc(points[0].day)} → ${esc(points[points.length - 1].day)} · ${points.length} day${points.length === 1 ? '' : 's'} of Shoes' own snapshots</small></figcaption></figure>`;++}++++/** The size-price curve as bars (real points only). */++function curveBars(curve) {++ if (!curve || !curve.points.length) return '<p class="sh-muted">Sizes are not stated by the sources yet, so there is no size-price curve. It appears when a store names the size.</p>';++ const max = Math.max(...curve.points.map((p) => p.min_price || 0), 1);++ return `<div class="sh-curve" role="list" aria-label="Lowest all-in price by size">${curve.points.slice(0, 40).map((p) => `<div class="sh-curve-point" role="listitem">`++ + `<span class="sh-curve-size">${esc(p.size)}</span><span class="sh-curve-bar" style="--h:${Math.max(4, Math.round(((p.min_price || 0) / max) * 100))}%"></span>`++ + `<b>${money(p.min_price)}</b><small>${p.vs_median_percent == null ? '' : `${pct(p.vs_median_percent)} vs median`}</small></div>`).join('')}</div>`;++}++++/** A size-price table, when sizes are known. */++function curveTable(curve) {++ if (!curve || !curve.points.length) return '<p class="sh-muted">Not known yet: the offers we read name no size. A size-price curve is shown only from offers that state one.</p>';++ return `<div class="sh-table-wrap"><table class="sh-table"><caption>Lowest all-in price per size, against the model's median size</caption><thead><tr><th scope="col">Size</th><th scope="col">Lowest all-in</th><th scope="col">vs median (${money(curve.median)})</th><th scope="col">Offers</th><th scope="col">Stores</th><th scope="col">Checked</th></tr></thead><tbody>`++ + curve.points.map((p) => `<tr><td>${esc(p.size)}</td><td>${money(p.min_price)}</td><td>${pct(p.vs_median_percent)}</td><td>${p.offers}</td><td>${p.stores}</td><td>${esc(checked(p.checked_at))}</td></tr>`).join('')++ + '</tbody></table></div>';++}++++function offersTable(offers) {++ if (!offers.length) return '<p class="sh-muted">No live offer yet. The next check may bring one.</p>';++ return `<div class="sh-table-wrap"><table class="sh-table sh-offers"><caption>Every current offer, cheapest all-in first</caption><thead><tr><th scope="col">Store</th><th scope="col">All-in</th><th scope="col">Price</th><th scope="col">Shipping</th><th scope="col">Size</th><th scope="col">Condition</th><th scope="col">Colourway</th><th scope="col">Checked</th><th scope="col">Freshness</th></tr></thead><tbody>`++ + offers.map((o) => `<tr><td><a href="${esc(o.url)}" rel="noopener nofollow">${esc(o.store_name || o.store)}</a><br><small>${esc(o.title)}</small></td>`++ + `<td><b>${money(o.all_in, o.currency)}</b>${o.drop_from_list ? `<br><small class="sh-good">−${money(o.drop_from_list, o.currency)} from list</small>` : ''}</td>`++ + `<td>${money(o.price, o.currency)}</td><td>${o.shipping_known ? money(o.shipping, o.currency) : '<span class="sh-muted">not stated</span>'}</td>`++ + `<td>${o.size ? esc(o.size) : '<span class="sh-muted">not stated</span>'}</td><td>${o.condition ? esc(o.condition) : '<span class="sh-muted">not stated</span>'}</td>`++ + `<td>${o.colourway ? esc(o.colourway) : '<span class="sh-muted">not stated</span>'}</td><td>${esc(checked(o.checked_at))}</td><td>${stale(o)}</td></tr>`).join('')++ + '</tbody></table></div>';++}++++function factsSection(facts) {++ const kinds = [['specs', 'Specs'], ['fit', 'Fit notes'], ['release', 'Release'], ['care', 'Care and repair']];++ return `<section class="sh-facts" aria-labelledby="sh-facts-h"><h2 id="sh-facts-h">Specs, fit, release and care</h2><p class="sh-muted">Only from cited open sources. When a source has not said it, the page says “not known yet”; Shoes never invents a spec.</p>`++ + kinds.map(([kind, label]) => {++ const rows = facts.filter((f) => f.kind === kind);++ return `<div class="sh-fact"><h3>${label}</h3>${rows.length ? `<dl>${rows.map((f) => `<dt>${esc(f.label)}</dt><dd>${esc(f.value)} <small>· ${sourceLine(f.source_name, f.source_url)}, read ${esc(dayShort(f.fetched_at))}</small></dd>`).join('')}</dl>` : '<p class="sh-muted">Not known yet.</p>'}</div>`;++ }).join('') + '</section>';++}++++function watchForm({ model, sizes, signedIn, next }) {++ if (!signedIn) return `<div class="sh-watch"><h2>Tell me when this drops below $X</h2><p>A price watch needs a person. ${link(`/auth/login?next=${encodeURIComponent(next)}`, 'Sign in with OpenVibe')} and the watch goes to OpenVibe.Watch.</p></div>`;++ return `<form class="sh-watch" method="post" action="/shoe/${encodeURIComponent(model.slug)}/watch"><h2>Tell me when this drops below $X at my size</h2>`++ + `<p>Watch the all-in price. Shoes hands the watch to OpenVibe.Watch, which carries the alert to your notifications.</p>`++ + `<div class="sh-form-row"><label>Lowest all-in price <input type="number" name="threshold" min="1" max="100000" step="0.01" required placeholder="90"></label>`++ + `<label>Size <select name="size"><option value="">Any size</option>${sizes.map((s) => `<option value="${esc(s)}">${esc(s)}</option>`).join('')}</select></label>`++ + `<label>Condition <select name="condition"><option value="">Any</option><option value="new">New</option><option value="used">Used</option><option value="deadstock">Deadstock</option></select></label>`++ + `<button class="sh-btn" type="submit">${icon('bell')} Watch this price</button></div></form>`;++}++++function searchForm({ q = '', facets = {}, action = '/shoes', values = {} }) {++ const brands = (facets.brands || []).slice(0, 40);++ const sizes = facets.sizes || [];++ return `<form class="sh-search" role="search" method="get" action="${action}">`++ + `<label class="sh-search-q">Search <input type="search" name="q" value="${esc(q)}" placeholder="A brand, a model, a colourway" autocomplete="off"></label>`++ + `<label>Brand <select name="brand"><option value="">Any brand</option>${brands.map((b) => `<option value="${esc(b.value)}"${values.brand === b.value ? ' selected' : ''}>${esc(b.value)} (${b.count})</option>`).join('')}</select></label>`++ + (sizes.length ? `<label>Size <select name="size"><option value="">Any size</option>${sizes.slice(0, 80).map((s) => `<option value="${esc(s.value)}"${values.size === s.value ? ' selected' : ''}>${esc(s.value)} (${s.count})</option>`).join('')}</select></label>` : '')++ + `<label>Condition <select name="condition"><option value="">Any</option><option value="new"${values.condition === 'new' ? ' selected' : ''}>New</option><option value="used"${values.condition === 'used' ? ' selected' : ''}>Used</option><option value="deadstock"${values.condition === 'deadstock' ? ' selected' : ''}>Deadstock</option></select></label>`++ + ((facets.stores || []).length ? `<label>Store <select name="store"><option value="">Any store</option>${(facets.stores || []).slice(0, 40).map((s) => `<option value="${esc(s.value)}"${values.store === s.value ? ' selected' : ''}>${esc(s.value)} (${s.count})</option>`).join('')}</select></label>` : '')++ + `<label>Sort <select name="sort"><option value="recent"${values.sort === 'recent' ? ' selected' : ''}>Recently checked</option><option value="drop"${values.sort === 'drop' ? ' selected' : ''}>Biggest drop</option><option value="price"${values.sort === 'price' ? ' selected' : ''}>Lowest all-in</option><option value="popular"${values.sort === 'popular' ? ' selected' : ''}>Most offers</option><option value="name"${values.sort === 'name' ? ' selected' : ''}>Name</option></select></label>`++ + `<button class="sh-btn" type="submit">${icon('search')} Search</button></form>`;++}++++// ── Home ────────────────────────────────────────────────────++function home({ facets = {}, dropping = [], dropsToday = [], popular = [], curveModel = null, curve = null, sceneDrop = null, jobs = [], now = Date.now() } = {}) {++ const brands = (facets.brands || []).slice(0, 5);++ const priceDrops = dropsToday.filter((m) => m.drop_amount).slice(0, 8);++ const popularRails = popular.slice(0, 8);++ const groupedDropping = groupDrops(dropping);++ const dropRail = groupedDropping.slice(0, 8);++ const lastChecked = facets.checked_at ? checked(facets.checked_at, now) : null;++ const jobsLine = jobs.filter((j) => j.finished_at).slice(0, 3).map((j) => `${j.source_name}: ${j.ok ? 'read' : 'failed'} ${dayShort(j.started_at)}`).join(' · ');++++ const scenes = [++ curve && curve.points.length && curveModel ? {++ title: 'What a size costs, in one curve',++ text: `${curveModel.brand} ${curveModel.model} in ${curve.points.length} sizes: ${pct(curve.points[0].vs_median_percent)} against the median size, from real offers that named a size. Sizes are read only when a store states one.`,++ html: curveBars(curve),++ action: { href: `/shoe/${encodeURIComponent(curveModel.slug)}`, label: `See ${curveModel.model}` },++ } : null,++ sceneDrop ? {++ title: 'A drop reminder, on Today',++ text: `Remind me on ${sceneDrop.brand} ${sceneDrop.model}: the release goes on your OpenVibe.Today agenda before it happens, with the official release page as its source. Raffle entries are never automated.`,++ html: `<div class="sh-scene-drop"><span class="sh-kicker">Today · reminder</span><b>${esc(sceneDrop.brand)} ${esc(sceneDrop.model)}${sceneDrop.colourway ? ` · ${esc(sceneDrop.colourway)}` : ''}</b>`++ + `<span>${esc(dayLong(sceneDrop.release_at))} at ${esc(clock(sceneDrop.release_at))} UTC</span><small>${sourceLine(sceneDrop.source_name, sceneDrop.source_url)}</small><span class="sh-scene-tag">on your agenda</span></div>`,++ action: { href: '/drops', label: 'Open the drops calendar' },++ } : null,++ {++ title: 'Paste a link, keep the price',++ text: 'A store, eBay or a Facebook Marketplace listing: paste the link and Shoes makes it a Watch. Marketplace has no public listings API and its terms forbid scraping, so Shoes never reads it itself; OpenVibe.Watch applies its own fetch rules.',++ html: `<form class="sh-scene-track" method="post" action="/track"><label for="sh-track-url">A listing link</label><div><input id="sh-track-url" type="url" name="url" placeholder="https://www.ebay.com/itm/…" required maxlength="2000"><button class="sh-btn" type="submit">${icon('link')} Track it</button></div><small>No Marketplace scraping, ever. The watch holds the link and what Watch may fetch by its own rules.</small></form>`,++ action: { href: '/track', label: 'Track a link' },++ },++ ].filter(Boolean);++++ // Real first (the owner, 10-31: empty sections made the rooms look unfinished): no drops today, no section.++ const priceDropSection = priceDrops.length++ ? rail({ id: 'sh-price-drops', title: 'The biggest price drops today', more: { href: '/shoes?sort=drop', label: 'Every drop' }, size: 's', items: priceDrops.map(modelCard) })++ : '';++ const body = sc.room({++ room: 'shoes',++ hero: {++ title: ROOM.line,++ lede: 'One page per shoe, every store\'s offer in one all-in price, the price history from Shoes\' own daily snapshots, and a watch that tells you when your size drops below your price.',++ search: { action: '/shoes', name: 'q', placeholder: 'Search a shoe, a brand or a model', label: 'Search shoes', button: 'Search', hints: ['Air Max 90', 'Samba', 'Pegasus', 'New Balance 990'] },++ chips: brands.map((b) => ({ label: b.value, href: `/shoes?brand=${encodeURIComponent(b.value)}` })),++ },++ proof: [++ { value: facets.models, label: facets.models === 1 ? 'model tracked' : 'models tracked' },++ { value: facets.offers, label: 'offers, all-in' },++ { value: (facets.stores || []).length, label: 'stores read' },++ { value: facets.drops, label: 'upcoming drops' },++ ].filter((p) => p.value),++ rails: [++ dropRail.length ? { id: 'sh-dropping', title: 'Dropping this week', more: { href: '/drops', label: 'The calendar' }, size: 's', items: dropRail.map(dropCard) } : null,++ popularRails.length ? { id: 'sh-popular', title: 'Popular models', more: { href: '/shoes?sort=popular', label: 'See all' }, size: 's', items: popularRails.map(modelCard) } : null,++ ].filter(Boolean),++ scenes,++ explore: {++ title: 'More on Shoes',++ items: [++ { title: 'Every shoe', text: 'Search by brand, model, size and condition; every price is all-in', href: '/shoes', icon: 'ov:search' },++ { title: 'The drops calendar', text: 'Official release dates, each cited to the brand\'s or store\'s own page', href: '/drops', icon: 'ov:calendar' },++ { title: 'Your watches', text: 'The price watches and pasted links that OpenVibe.Watch carries', href: '/watches', icon: 'ov:bell' },++ { title: 'The API', text: 'Shoes, one shoe with its history and curve, drops, watches and open tracking', href: '/developers', icon: 'ov:code' },++ ],++ },++ dev: {++ title: 'For developers',++ lede: 'Every model, offer, snapshot, drop and watch is open JSON. No key for the public reads; the offers are always all-in and dated.',++ samples: [++ { label: 'Search shoes', lang: 'bash', code: `curl "https://open.shoes/api/v1/shoes?q=air+max&size=10.5&sort=price"` },++ { label: 'One shoe, with history and curve', lang: 'bash', code: `curl https://open.shoes/api/v1/shoes/nike-air-max-90` },++ { label: 'The drops calendar', lang: 'bash', code: `curl https://open.shoes/api/v1/drops` },++ ],++ links: [{ label: 'The API', href: '/developers' }, { label: 'OpenAPI', href: '/api/openapi.json' }, { label: 'llms.txt', href: '/llms.txt' }],++ },++ faq: {++ items: [++ ['Where do the prices come from?', 'From OpenVibe.Deals\' public offers (the stores\' own pages, read by the network\'s sources) and, when a host sets a key pair, the official eBay Browse API. Each offer keeps its store, its currency, the time it was checked and a stale flag. Nothing is sponsored, and there are no affiliate links.'],++ ['What is "all-in"?', 'The price plus the shipping when the store states shipping. When the store states no shipping, the page says the shipping is not stated; it never invents one. Every offer is per exact variant, size and condition where the source gives them.'],++ ['Why is a spec "not known yet"?', 'Specs, fit notes, release facts and care notes are published only from cited open sources. Shoes never invents them, so a model with no cited source says "not known yet".'],++ ['Do you scrape Facebook Marketplace?', 'No. Marketplace has no public listings API and its terms forbid scraping. A Marketplace link is tracked by pasting it: the watch holds the link, and OpenVibe.Watch applies its own fetch rules.'],++ ['Do you enter raffles for me?', 'Never. The drops calendar shows the official release page and puts a reminder on your Today agenda. Stores\' terms forbid automated entries, so Shoes does not build them.'],++ [lastChecked ? `When was the catalogue last read? ${lastChecked}.` : 'When was the catalogue last read?', jobsLine || 'The jobs read OpenVibe.Deals every 30 minutes and the official release pages every 6 hours. The exact last read is on every model page and in /api/v1/status.'],++ ],++ },++ network: {++ service: 'shoes',++ title: 'Part of OpenVibe',++ lede: 'Shoes is one room of the network. The offers come from Deals, the alerts ride Watch, and the reminders land on Today.',++ items: [++ { to: 'deals', what: 'The network\'s offer aggregator: stores, prices and observations', href: 'https://openvibe.deals' },++ { to: 'watch', what: 'Watches pages, feeds and APIs, and carries the alert', href: 'https://openvibe.watch' },++ { to: 'today', what: 'Your agenda: every drop reminder in one day', href: 'https://openvibe.today' },++ ],++ },++ });++ // The price-drop section is a real rail when there are drops and an honest note when there are none; it sits++ // directly under the hero, before the "dropping this week" and "popular models" rails.++ const railAt = body.indexOf('<section class="ovk-rail');++ const sceneAt = body.indexOf('<section class="sc-room-scenes"');++ const at = railAt >= 0 ? railAt : sceneAt;++ return at < 0 ? `${body}${priceDropSection}` : `${body.slice(0, at)}${priceDropSection}${body.slice(at)}`;++}++++// ── Search ──────────────────────────────────────────────────++function shoesIndex({ models = [], facets = {}, query = {}, page = 1, total = 0, now = Date.now() } = {}) {++ const perPage = 24;++ const pages = Math.max(1, Math.ceil(total / perPage));++ const qs = (n) => {++ const p = new URLSearchParams();++ for (const k of ['q', 'brand', 'size', 'condition', 'sort']) if (query[k]) p.set(k, query[k]);++ if (n > 1) p.set('page', String(n));++ return p.toString();++ };++ const body = `<div class="sh-wrap sh-page">++<header class="sh-page-h"><p class="sh-kicker">Every shoe</p><h1>Search the catalogue</h1><p class="sh-lede">${total} model${total === 1 ? '' : 's'}${query.q ? ` for “${esc(query.q)}”` : ''}, each with every store's all-in price, the time it was checked and a stale flag.${facets.checked_at ? ` The catalogue was last read ${esc(checked(facets.checked_at, now))}.` : ''}</p></header>++${searchForm({ q: query.q || '', facets, values: query })}++${models.length ? `<ul class="sh-grid">${models.map((m) => `<li>${modelCard(m)}</li>`).join('')}</ul>` : `<div class="sh-empty"><h2>No shoe matches yet</h2><p>Try a brand, a model or a shorter word. The catalogue is filled by the Deals job; a model appears the first time a source names it.</p><p><a class="sh-btn" href="/shoes">Clear the filters</a></p></div>`}++${pages > 1 ? `<nav class="sh-page-nav" aria-label="Pages"><a${page <= 1 ? ' class="is-off"' : ''} href="/shoes?${esc(qs(page - 1))}">← Previous</a><span>Page ${page} of ${pages}</span><a${page >= pages ? ' class="is-off"' : ''} href="/shoes?${esc(qs(page + 1))}">Next →</a></nav>` : ''}++<p class="sh-fine">A model with no price says so; a price that has not been checked in ${esc(facets.stale_hours || 48)} hours is marked stale. No affiliate links, ever.</p></div>`;++ return body;++}++++// ── One shoe ────────────────────────────────────────────────++function shoe({ model, offers = [], history = [], curve = { points: [], median: null }, facts = [], drops: releases = [], signedIn = false, now = Date.now() } = {}) {++ if (!model) return notFound();++ const prices = offers.map((o) => o.all_in).filter((p) => p != null);++ const low = prices.length ? Math.min(...prices) : null;++ const high = prices.length ? Math.max(...prices) : null;++ const sizes = [...new Set(offers.map((o) => o.size).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));++ const colours = [...new Set([...offers.map((o) => o.colourway), ...releases.map((d) => d.colourway)].filter(Boolean))];++ const stores = [...new Set(offers.map((o) => o.store))];++ const staleCount = offers.filter((o) => o.stale).length;++ const lastChecked = offers.map((o) => o.checked_at).filter(Boolean).sort().pop() || null;++ const body = `<div class="sh-wrap sh-page">++<nav class="crumbs" aria-label="Breadcrumbs"><a href="/">Home</a> / <a href="/shoes">Shoes</a> / ${esc(model.brand)} ${esc(model.model)}</nav>++<header class="sh-shoe-h"><p class="sh-kicker">${esc(model.brand)}</p><h1>${esc(model.model)}</h1>++<p class="sh-lede">${low == null ? 'No live price yet.' : `From <b>${money(low)}</b>${high != null && high !== low ? ` to ${money(high)}` : ''} all-in at ${stores.length} store${stores.length === 1 ? '' : 's'}.`} ${lastChecked ? esc(checked(lastChecked, now)) : ''}${staleCount ? ` · <span class="sh-stale">${staleCount} stale</span>` : ''}</p>++<p>${sizes.length ? `${sizes.length} size${sizes.length === 1 ? '' : 's'} priced: ${sizes.slice(0, 12).map(esc).join(', ')}` : 'Sizes not stated by the sources yet.'}</p></header>++${watchForm({ model, sizes, signedIn, next: `/shoe/${model.slug}` })}++<section class="sh-sec" aria-labelledby="sh-prices-h"><h2 id="sh-prices-h">Current prices by store</h2><p class="sh-fine">Every offer is the exact listing the store published, with its shipping when stated. A price older than ${esc(48)} hours is stale; the page never hides it.</p>${offersTable(offers)}</section>++<section class="sh-sec" aria-labelledby="sh-history-h"><h2 id="sh-history-h">Price history</h2><p class="sh-fine">One snapshot per offer per day, taken from Shoes' own checks. The line is the lowest all-in price of each day.</p>${historyChart(history)}</section>++<section class="sh-sec" aria-labelledby="sh-curve-h"><h2 id="sh-curve-h">Size-price curve</h2>${curveTable(curve)}</section>++<section class="sh-sec" aria-labelledby="sh-colours-h"><h2 id="sh-colours-h">Colourways</h2>${colours.length ? `<ul class="sh-chips">${colours.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>` : '<p class="sh-muted">Not known yet: no offer names a colourway.</p>'}</section>++${releases.length ? `<section class="sh-sec" aria-labelledby="sh-releases-h"><h2 id="sh-releases-h">Release dates</h2><p class="sh-fine">From the brand's or the store's own release page, cited. Shoes never enters a raffle.</p><ul class="sh-list">${releases.map((d) => `<li><b>${esc(dayLong(d.release_at))}</b> · ${esc(d.colourway || 'colourway not stated')}${d.price != null ? ` · ${money(d.price, d.currency)}` : ''}${d.sizes.length ? ` · sizes ${d.sizes.slice(0, 12).map(esc).join(', ')}` : ''} · ${sourceLine(d.source_name, d.source_url)}${signedIn ? ` · <a href="/drops#drop-${encodeURIComponent(d.id)}">Remind me</a>` : ''}</li>`).join('')}</ul></section>` : ''}++${factsSection(facts)}++<p class="sh-fine">The release facts and care notes above are the source's words, cited with the page and the date Shoes read it. The stores' own pages are the only place to buy; Shoes never takes an order.</p></div>`;++ return body;++}++++// ── Drops ───────────────────────────────────────────────────++function drops({ drops: list = [], signedIn = false, notice = null, now = Date.now() } = {}) {++ const upcoming = groupDrops(list.filter((d) => Date.parse(d.release_at) >= now - 3600_000));++ const byDay = new Map();++ for (const d of upcoming) {++ const key = String(d.release_at).slice(0, 10);++ if (!byDay.has(key)) byDay.set(key, []);++ byDay.get(key).push(d);++ }++ const next7 = upcoming.filter((d) => Date.parse(d.release_at) <= now + 7 * 86400_000);++ const body = `<div class="sh-wrap sh-page">++<header class="sh-page-h"><p class="sh-kicker">The drops calendar</p><h1>Dropping this week and after</h1><p class="sh-lede">Official release dates only, each read from the brand's or the store's own public release page, with the page and the time it was read. ${next7.length} release${next7.length === 1 ? '' : 's'} in the next seven days.</p></header>++${notice ? `<div class="sh-notice" role="status">${esc(notice)}</div>` : ''}++<p class="sh-fine"><b>Never automated entries.</b> Shoes shows the official page and can put a reminder on your Today agenda. It does not enter raffles or draws; stores' terms forbid bots. Reservations only where a store offers them officially.</p>++${upcoming.length ? [...byDay.entries()].map(([day, rows]) => `<section class="sh-drop-day" aria-labelledby="sh-day-${esc(day)}"><h2 id="sh-day-${esc(day)}">${esc(dayLong(rows[0].release_at))}</h2><ul class="sh-drops">${rows.map((d) => `<li class="sh-drop" id="drop-${esc(d.id)}"><div><b>${esc(d.brand)} ${esc(d.model)}</b>${d.colourway ? ` <span class="sh-colour">${esc(d.colourway)}</span>` : ''}<span class="sh-drop-meta">${esc(clock(d.release_at))} UTC · ${d.price != null ? money(d.price, d.currency) : 'price not stated'}${d.sizes.length ? ` · ${d.sizes.length} sizes` : ''}</span><span class="sh-drop-src">${sourceLine(d.source_name, d.source_url)} · read ${esc(dayShort(d.fetched_at))}</span></div>${signedIn ? `<form method="post" action="/drops/${encodeURIComponent(d.id)}/remind"><button class="sh-btn sh-btn-quiet" type="submit">${icon('bell')} Remind me</button></form>` : `<a class="sh-btn sh-btn-quiet" href="/auth/login?next=${encodeURIComponent('/drops')}">${icon('bell')} Sign in to be reminded</a>`}</li>`).join('')}</ul></section>`).join('') : '<div class="sh-empty"><h2>No release is listed right now</h2><p>The release pages answered but published no dated release in the window. The calendar fills on the next read.</p></div>'}++</div>`;++ return body;++}++++// ── Watches ─────────────────────────────────────────────────++function watches({ watches: list = [], signedIn = false, notice = null } = {}) {++ if (!signedIn) {++ return `<div class="sh-wrap sh-page"><header class="sh-page-h"><p class="sh-kicker">Your watches</p><h1>Sign in to see your watches</h1><p class="sh-lede">A watch belongs to a person. Sign in with OpenVibe and your price watches and pasted links are here.</p><p><a class="sh-btn" href="/auth/login?next=${encodeURIComponent('/watches')}">Sign in with OpenVibe</a></p></header></div>`;++ }++ const body = `<div class="sh-wrap sh-page"><header class="sh-page-h"><p class="sh-kicker">Your watches</p><h1>Watches</h1><p class="sh-lede">Every watch is carried by OpenVibe.Watch. A price watch tells you when the all-in price for your size drops below your number; a pasted link keeps the listing and lets Watch apply its own fetch rules.</p></header>++${notice ? `<div class="sh-notice" role="status">${esc(notice)}</div>` : ''}++${list.length ? `<div class="sh-table-wrap"><table class="sh-table"><caption>Your watches, newest first</caption><thead><tr><th scope="col">Kind</th><th scope="col">What</th><th scope="col">Size</th><th scope="col">Below</th><th scope="col">Status</th><th scope="col">Created</th></tr></thead><tbody>${list.map((w) => `<tr><td>${esc(w.kind)}</td><td>${w.slug ? link(`/shoe/${encodeURIComponent(w.slug)}`, w.slug) : w.source_url ? `<a href="${esc(w.source_url)}" rel="noopener nofollow">${esc(w.source_url)}</a>` : '—'}${w.note ? `<br><small>${esc(w.note)}</small>` : ''}</td><td>${w.size ? esc(w.size) : 'any'}</td><td>${w.threshold != null ? money(w.threshold, w.currency) : '—'}</td><td>${esc(w.status)}${w.remote_id ? `<br><small>Watch ${esc(w.remote_id)}</small>` : ''}</td><td>${esc(dayShort(w.created_at))}</td></tr>`).join('')}</tbody></table></div>` : '<div class="sh-empty"><h2>No watch yet</h2><p>Open a shoe and set the price for your size, or paste a listing link on the home page.</p><p><a class="sh-btn" href="/shoes">Find a shoe</a></p></div>'}++</div>`;++ return body;++}++++// ── Developers ──────────────────────────────────────────────++function developers({ baseUrl = 'https://open.shoes' } = {}) {++ const api = String(baseUrl).replace(/\/+$/, '');++ const rows = [++ ['GET', '/api/v1/shoes?q&brand&size&condition&store&sort&page', 'Search the catalogue. `sort`: recent, drop, price, price_desc, popular, name. The answer carries the facets.'],++ ['GET', '/api/v1/shoes/:slug', 'One model: its offers, its daily price history, the size-price curve and the cited facts.'],++ ['GET', '/api/v1/shoes/:slug/price?size=&condition=', 'The lowest all-in price for a model (and size), for a Watch to poll.'],++ ['GET', '/api/v1/drops?from&to', 'The official release dates, soonest first, each with its source page and read time.'],++ ['GET', '/api/v1/status', 'The last read of each source: when, how many rows, whether it answered.'],++ ['POST', '/api/v1/me/watches', 'Session only: { slug, size?, condition?, threshold, currency? } → a price watch in OpenVibe.Watch.'],++ ['GET', '/api/v1/me/watches', 'Session only: the person\'s watches.'],++ ['POST', '/api/v1/me/track', 'Session only: { url } → a Watch for a pasted listing, through Watch\'s own fetch rules.'],++ ['GET', '/api/openapi.json', 'This API as an OpenAPI 3.1 document.'],++ ];++ const body = `<div class="sh-wrap sh-page">++<header class="sh-page-h"><p class="sh-kicker">Developers</p><h1>The Shoes API</h1><p class="sh-lede">Every model, offer, snapshot and drop is open JSON. The public reads take no key; a write is a signed-in person's own. There are no affiliate links and no Marketplace scraping.</p></header>++<section class="sh-sec" aria-labelledby="sh-api-h"><h2 id="sh-api-h">Routes</h2><div class="sh-table-wrap"><table class="sh-table"><thead><tr><th scope="col">Method</th><th scope="col">Route</th><th scope="col">What it does</th></tr></thead><tbody>${rows.map(([m, r, d]) => `<tr><td><code>${m}</code></td><td><code>${esc(r)}</code></td><td>${esc(d)}</td></tr>`).join('')}</tbody></table></div>++<p><a class="sh-btn" href="/api/openapi.json">OpenAPI 3.1</a> <a class="sh-btn sh-btn-quiet" href="/api/v1/shoes?q=air">Try a search</a></p></section>++<section class="sh-sec" aria-labelledby="sh-src-h"><h2 id="sh-src-h">Where the data comes from</h2><ul class="sh-list"><li><b>OpenVibe.Deals</b> is the network's offer aggregator: Shoes reads its public newest-offer pages and keeps the footwear, then reads each product's public record for its brand.</li><li><b>eBay</b> is read through the official Browse API, and only when the host sets a developer key pair.</li><li><b>Release pages</b> are the brand's or the store's own public release calendar (Nike SNKRS by default), read by a pure parser. A release with no date is not imported.</li><li><b>No scraping</b> of Facebook Marketplace; a Marketplace link is tracked by the person pasting it and Watch applying its own rules.</li></ul></section>++<section class="sh-sec" aria-labelledby="sh-ex-h"><h2 id="sh-ex-h">Examples</h2><pre class="sh-code"><code>curl "${api}/api/v1/shoes?q=air+max&size=10.5&sort=price"</code></pre>++<pre class="sh-code"><code>curl "${api}/api/v1/shoes/nike-air-max-90"</code></pre>++<pre class="sh-code"><code>curl "${api}/api/v1/drops"</code></pre></section>++<p class="sh-fine">Errors are <code>application/problem+json</code> with a <code>code</code>. The public reads are rate-limited per address; a signed-in write also needs this site's origin.</p></div>`;++ return body;++}++++function track({ signedIn = false, notice = null, url = '' } = {}) {++ return `<div class="sh-wrap sh-page"><header class="sh-page-h"><p class="sh-kicker">Paste a link</p><h1>Track a listing</h1><p class="sh-lede">A store, eBay or a Facebook Marketplace listing: paste the link and it becomes a Watch. OpenVibe.Watch applies its own fetch rules; Shoes never scrapes Marketplace, and never reads a page itself.</p></header>++${notice ? `<div class="sh-notice" role="status">${esc(notice)}</div>` : ''}++${signedIn ? `<form class="sh-track-form" method="post" action="/track"><label for="sh-track-url">The listing link</label><input id="sh-track-url" type="url" name="url" value="${esc(url)}" placeholder="https://www.ebay.com/itm/…" required maxlength="2000"><button class="sh-btn" type="submit">${icon('link')} Track it</button></form>` : `<p><a class="sh-btn" href="/auth/login?next=${encodeURIComponent('/track')}">Sign in with OpenVibe</a> and paste the link.</p>`}++<section class="sh-sec"><h2>What Shoes will and will not do</h2><ul class="sh-list"><li><b>Will</b> keep the link as a Watch with OpenVibe.Watch, which fetches it under its own rules and tells you when it changes.</li><li><b>Will not</b> scrape Facebook Marketplace, or any site whose terms forbid it.</li><li><b>Will not</b> buy, bid, reserve or enter anything for you.</li></ul></section></div>`;++}++++function signIn({ title = 'Sign in', next = '/' } = {}) {++ return `<div class="sh-wrap sh-page"><header class="sh-page-h"><p class="sh-kicker">OpenVibe account</p><h1>${esc(title)}</h1><p class="sh-lede">This page belongs to a person. Sign in with OpenVibe; you come back to where you were.</p><p><a class="sh-btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a></p></header></div>`;++}++++function notFound() {++ return `<div class="sh-wrap sh-page"><header class="sh-page-h"><p class="sh-kicker">404</p><h1>No such page</h1><p class="sh-lede">That shoe, drop or page is not here. The catalogue is at <a href="/shoes">/shoes</a>, the drops at <a href="/drops">/drops</a>.</p><p><a class="sh-btn" href="/">Home</a></p></header></div>`;++}++++module.exports = { home, shoesIndex, shoe, drops, watches, track, developers, signIn, notFound, modelCard, dropCard, groupDrops, money, historyChart, curveBars, offersTable };nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++/**++ * ADR-033: Shoes' part of an account export (a person's watches) and of a deletion, applied through++ * /internal/events with a stand-in Network. A deletion erases the person's watches and leaves other people's and++ * the network's catalogue alone. A redelivery changes nothing; a bad signature and a proxied request are refused.++ */++const assert = require('assert');++const http = require('http');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++const { createNetworkSender } = require('openvibe-sdk/account-data');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++++const A = ids.newId('user');++const B = ids.newId('user');++const EXP = `exp_${ids.ulid()}`;++const DEL = `del_${ids.ulid()}`;++const SECRET = `fixture_${'account'.repeat(6)}`;++const WRONG = `fixture_${'mismatch'.repeat(6)}`;++++const exportEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXP, subject: A } };++const deleteEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DEL, subject: A, aliases: [] } };++const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });++++async function startNetworkStub() {++ const calls = [];++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks);++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_shoes', token_type: 'Bearer', expires_in: 300 });++ calls.push({ url: req.url, auth: req.headers.authorization, body: JSON.parse(raw.toString() || 'null') });++ return json(201, {});++ });++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };++}++++(async () => {++ const stub = await startNetworkStub();++ const sender = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'shoes', clientSecret: 'shoes-secret' });++ const t = await boot({ env: { SHOES_EVENTS_SECRET: SECRET }, accountSend: sender });++ try {++ const db = t.ctx.s.db;++ const watchA = `shw_${ids.ulid()}`;++ const watchB = `shw_${ids.ulid()}`;++ await db.exec(++ `INSERT INTO shoe_watches (id, subject, kind, slug, size, threshold_amount, currency, status)++ VALUES ($1, $2, 'price', 'nike-air-max-90', '10.5', 90, 'USD', 'active'), ($3, $4, 'link', NULL, NULL, NULL, 'USD', 'active')`,++ [watchA, A, watchB, B],++ );++++ await check('an export carries only A\'s watches', async () => {++ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.json().outcome, 'exported');++ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);++ assert.ok(part, 'the part was pushed to Network');++ assert.strictEqual(part.auth, 'Bearer tok_shoes');++ assert.strictEqual(part.body.subject, A);++ assert.deepStrictEqual(part.body.files.map((f) => f.name), ['watches.json']);++ const watches = part.body.files[0].content;++ assert.deepStrictEqual(watches.map((w) => w.id), [watchA]);++ });++++ await check('a deletion erases A\'s watches and confirms with counts; B stays', async () => {++ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.json().outcome, 'erased');++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM shoe_watches WHERE subject = $1', [A])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM shoe_watches WHERE subject = $1', [B])), 1);++ const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);++ assert.ok(confirmation, 'the confirmation was sent');++ assert.ok(confirmation.body.erased.shoe_watches >= 1);++ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));++ });++++ await check('a redelivery changes nothing', async () => {++ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });++ assert.strictEqual(res.status, 200);++ assert.strictEqual(res.json().outcome, 'unchanged');++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM shoe_watches WHERE subject = $1', [A])), 0);++ });++++ await check('the internal route refuses a bad signature (401) and a forwarded request (403)', async () => {++ const bad = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), WRONG) } });++ assert.strictEqual(bad.status, 401);++ const forwarded = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });++ assert.strictEqual(forwarded.status, 403);++ });++ } finally {++ await t.close();++ await stub.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,173 @@++'use strict';++++/**++ * Every /api/v1 route on a booted app: the public search and one shoe, the price a Watch polls, the drops, the++ * signed-in price watch and pasted link (through the Watch stand-in), the cross-site refusal, the source status++ * and the OpenAPI document.++ */++const { boot, check, done } = require('./helpers/boot');++const { assert, equal } = require('./helpers/check');++++(async () => {++ const t = await boot();++ try {++ await t.ctx.jobs.offers.runOnce({ pages: 2 });++ await t.ctx.jobs.drops.runOnce();++ const user = t.network.addUser('ada');++++ await check('GET /api/v1/shoes: the catalogue and its facets', async () => {++ const res = await t.get('/api/v1/shoes');++ equal(res.status, 200);++ const b = res.json();++ equal(b.shoes.length, 4);++ equal(b.total, 4);++ equal(b.facets.models, 4);++ equal(b.facets.offers, 4);++ assert(b.facets.brands.some((x) => x.value === 'Nike'), 'brand facet');++ assert(b.shoes.every((s) => s.offer_count === 1), 'one offer each');++ });++++ await check('GET /api/v1/shoes?brand&size&sort: the filters', async () => {++ const nike = (await t.get('/api/v1/shoes?brand=Nike')).json();++ equal(nike.shoes.length, 1);++ equal(nike.shoes[0].slug, 'nike-air-max-90');++ const sized = (await t.get('/api/v1/shoes?size=10.5')).json();++ equal(sized.shoes.length, 1);++ equal(sized.shoes[0].slug, 'adidas-samba-og');++ const sorted = (await t.get('/api/v1/shoes?sort=price')).json();++ equal(sorted.shoes[0].slug, 'nike-air-max-90');++ });++++ await check('GET /api/v1/shoes/:slug: offers, history, curve and facts', async () => {++ const res = await t.get('/api/v1/shoes/nike-air-max-90');++ equal(res.status, 200);++ const b = res.json();++ equal(b.shoe.brand, 'Nike');++ equal(b.offers.length, 1);++ equal(b.offers[0].all_in, 89.95);++ equal(b.history.length, 1);++ equal(b.history[0].day, '2026-10-31');++ equal(b.curve.points.length, 0, 'no size named on this offer');++ equal(b.facts.length, 0);++ });++++ await check('GET /api/v1/shoes/:slug/price: the Watch feed', async () => {++ const res = await t.get('/api/v1/shoes/nike-air-max-90/price');++ equal(res.status, 200);++ const b = res.json();++ equal(b.price, 89.95);++ equal(b.offers, 1);++ assert(b.checked_at, 'the check time');++ equal((await t.get('/api/v1/shoes/no-such-shoe/price')).status, 404);++ equal((await t.get('/api/v1/shoes/NOT_A_SLUG')).status, 404);++ });++++ await check('GET /api/v1/drops: the official releases', async () => {++ const res = await t.get('/api/v1/drops');++ equal(res.status, 200);++ const b = res.json();++ equal(b.drops.length, 3);++ assert(b.drops.every((d) => d.source_url), 'every drop cites its source');++ const one = await t.get(`/api/v1/drops/${encodeURIComponent(b.drops[0].id)}`);++ equal(one.status, 200);++ equal(one.json().model, b.drops[0].model);++ equal((await t.get('/api/v1/drops/not-a-drop')).status, 404);++ equal((await t.get('/api/v1/drops?from=not-a-date')).status, 400);++ });++++ await check('POST /api/v1/me/watches: a session is required', async () => {++ const res = await t.get('/api/v1/me/watches', { json: { slug: 'nike-air-max-90', threshold: 90 }, method: 'POST' });++ equal(res.status, 401);++ equal(res.json().code, 'token.required');++ });++++ await check('POST /api/v1/me/watches: a price watch through OpenVibe.Watch', async () => {++ const res = await t.get('/api/v1/me/watches', { as: user, json: { slug: 'nike-air-max-90', size: '10.5', condition: 'new', threshold: 90 } });++ equal(res.status, 201);++ const b = res.json();++ equal(b.watch.kind, 'price');++ equal(b.watch.status, 'active');++ assert(b.watch.remote_id, 'a remote Watch id');++ assert(b.remote, 'the remote watch');++ const sent = t.upstreams.state.watchRequests[0];++ equal(sent.body.source.kind, 'api');++ assert(sent.body.source.url.includes('/api/v1/shoes/nike-air-max-90/price'), 'the feed URL');++ equal(sent.body.condition.op, 'lt');++ equal(sent.body.condition.value, 90);++ equal(sent.headers['x-ov-subject'], user.subject);++ const list = (await t.get('/api/v1/me/watches', { as: user })).json();++ equal(list.watches.length, 1);++ });++++ await check('POST /shoe/:slug/watch: the page form creates the same price watch', async () => {++ const before = t.upstreams.state.watchRequests.length;++ const res = await t.get('/shoe/nike-air-max-90/watch', { as: user, form: { threshold: '75', size: '10.5', condition: 'new' } });++ equal(res.status, 302);++ assert(res.headers.get('location').startsWith('/watches?notice='), 'to the watches with a note');++ equal(t.upstreams.state.watchRequests.length, before + 1);++ const sent = t.upstreams.state.watchRequests[before];++ equal(sent.body.condition.value, 75);++ equal(sent.body.source.kind, 'api');++ });++++ await check('POST /api/v1/me/track: a pasted link becomes a Watch', async () => {++ const res = await t.get('/api/v1/me/track', { as: user, json: { url: 'https://www.ebay.com/itm/123' } });++ equal(res.status, 201);++ const b = res.json();++ equal(b.watch.kind, 'link');++ equal(b.watch.source_url, 'https://www.ebay.com/itm/123');++ const sent = t.upstreams.state.watchRequests[t.upstreams.state.watchRequests.length - 1];++ equal(sent.body.source.kind, 'http');++ equal(sent.body.condition.op, 'changed');++ equal(sent.body.extraction.kind, 'text');++ equal((await t.get('/api/v1/me/track', { as: user, json: { url: '' } })).status, 400);++ equal((await t.get('/api/v1/me/track', { as: user, json: { url: 'ftp://example.com/x' } })).status, 400);++ });++++ await check('a signed-in write from another site is refused', async () => {++ const res = await t.get('/api/v1/me/watches', { as: user, origin: 'https://evil.example', json: { slug: 'nike-air-max-90', threshold: 90 } });++ equal(res.status, 403);++ equal(res.json().code, 'shoes.cross_site');++ });++++ await check('GET /api/health: the machine endpoint', async () => {++ const res = await t.get('/api/health');++ equal(res.status, 200);++ equal(res.json().service, 'openvibe-shoes');++ equal(res.json().name, 'OpenVibe.Shoes');++ });++++ await check('GET /api/v1/status: the jobs, counts and what is configured', async () => {++ const b = (await t.get('/api/v1/status')).json();++ equal(b.counts.models, 4);++ equal(b.sources.watch, 'configured');++ equal(b.sources.ebay, 'configured');++ assert(b.jobs.some((j) => j.job === 'offers'), 'the offers job is listed');++ assert(b.jobs.some((j) => j.job === 'drops'), 'the drops job is listed');++ });++++ await check('GET /api/openapi.json: a valid OpenAPI 3.1 document with every route', async () => {++ const res = await t.get('/api/openapi.json');++ equal(res.status, 200);++ const b = res.json();++ equal(b.openapi, '3.1.0');++ assert(b.paths['/api/v1/shoes'], 'shoes');++ assert(b.paths['/api/v1/shoes/{slug}'], 'one shoe');++ assert(b.paths['/api/v1/shoes/{slug}/price'], 'price');++ assert(b.paths['/api/v1/drops'], 'drops');++ assert(b.paths['/api/v1/me/watches'], 'watches');++ assert(b.paths['/api/v1/me/track'], 'track');++ equal((await t.get('/api/v1/openapi.json')).status, 200);++ });++++ await check('an unknown API route is problem+json', async () => {++ const res = await t.get('/api/v1/nope');++ equal(res.status, 404);++ assert(res.headers.get('content-type').includes('application/problem+json'), 'problem+json');++ equal(res.json().code, 'route.not_found');++ });++ } finally {++ await t.close();++ }++ done();++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,195 @@++'use strict';++++/**++ * The catalogue (server/catalogue.js) on a real migrated PGlite database: one model per brand + model, one snapshot++ * per offer per day, all-in prices, the list-price and day-over-day drop, the size curve, drops, cited facts, a++ * person's watches and the job runs.++ */++const { check, done, assert, equal } = require('./helpers/check');++const { testDb } = require('./helpers/db');++const { createStore } = require('../server/db');++const { createCatalogue } = require('../server/catalogue');++++(async () => {++ const test = await testDb();++ let clock = Date.parse('2026-10-31T22:00:00Z');++ const s = createStore(test.db, { now: () => clock });++ const c = createCatalogue(s, { staleHours: 48 });++ const user = 'usr_01J00000000000000000000AA';++++ try {++ await check('upsertOffer creates a model from the product and the source brand', async () => {++ const a = await c.upsertOffer({++ source: 'deals', sourceId: 'deal-1', title: 'Nike Air Max 90 Running Shoe - White',++ modelName: 'Air Max 90', brand: 'Nike', url: 'https://www.zappos.com/p/1', store: 'zappos.com', storeName: 'Zappos',++ price: '100.00', listPrice: '130.00', shipping: '5.99', condition: 'New', size: '10.5', colourway: 'White',++ checkedAt: '2026-10-31T20:00:00Z', raw: { id: 'deal-1' },++ });++ assert(a, 'offer written');++ const model = await c.modelBySlug('nike-air-max-90');++ assert(model, 'model slug is nike-air-max-90');++ equal(model.brand, 'Nike');++ equal(model.model, 'Air Max 90');++ const offers = await c.offersOf(model.id);++ equal(offers.length, 1);++ equal(offers[0].all_in, 105.99);++ equal(offers[0].drop_from_list, 24.01);++ equal(offers[0].size, '10.5');++ equal(offers[0].colourway, 'White');++ equal(offers[0].stale, false);++ });++++ await check('a second offer on the same model joins it; a duplicate source id updates', async () => {++ const b = await c.upsertOffer({++ source: 'deals', sourceId: 'deal-2', title: 'Nike Air Max 90 Running Shoe',++ modelName: 'Air Max 90', brand: 'Nike', url: 'https://www.footlocker.com/p/2', store: 'footlocker.com',++ price: '95.00', shipping: null, condition: 'new', size: '9', checkedAt: '2026-10-31T20:30:00Z',++ });++ assert(b, 'second offer');++ const model = await c.modelBySlug('nike-air-max-90');++ equal((await c.offersOf(model.id)).length, 2);++ await c.upsertOffer({++ source: 'deals', sourceId: 'deal-2', title: 'Nike Air Max 90 Running Shoe',++ modelName: 'Air Max 90', brand: 'Nike', url: 'https://www.footlocker.com/p/2', store: 'footlocker.com',++ price: '90.00', shipping: null, condition: 'new', size: '9', checkedAt: '2026-10-31T21:30:00Z',++ });++ const offers = await c.offersOf(model.id);++ equal(offers.length, 2, 'still two offers');++ equal(offers.find((o) => o.source === 'deals' && o.price === 90).all_in, 90);++ });++++ await check('history: one snapshot per offer per day, the first check wins', async () => {++ const model = await c.modelBySlug('nike-air-max-90');++ const history = await c.history(model.id);++ equal(history.length, 1);++ equal(history[0].day, '2026-10-31');++ equal(history[0].offers, 2);++ equal(history[0].price, 95, 'the lowest offer of the day');++ });++++ await check('a next-day check adds a point and a day-over-day drop', async () => {++ const model = await c.modelBySlug('nike-air-max-90');++ await c.upsertOffer({++ source: 'deals', sourceId: 'deal-2', title: 'Nike Air Max 90 Running Shoe',++ modelName: 'Air Max 90', brand: 'Nike', url: 'https://www.footlocker.com/p/2', store: 'footlocker.com',++ price: '70.00', shipping: null, condition: 'new', size: '9', checkedAt: '2026-11-01T09:00:00Z',++ });++ const history = await c.history(model.id);++ equal(history.length, 2);++ const listed = await c.listShoes({ brand: 'Nike', sort: 'drop' });++ assert(listed[0].drop_amount >= 24, 'the list-price drop is still the biggest');++ equal(listed[0].offer_count, 2);++ equal(listed[0].store_count, 2);++ });++++ await check('curve: the lowest all-in per size, against the median', async () => {++ const model = await c.modelBySlug('nike-air-max-90');++ const curve = await c.curve(model.id);++ equal(curve.points.length, 2);++ equal(curve.points[0].size, '9');++ equal(curve.points[1].size, '10.5');++ assert(curve.median != null, 'a median size price');++ assert(curve.points.every((p) => p.vs_median_percent != null), 'every point says how it compares');++ });++++ await check('listShoes: paging, filters and facets', async () => {++ const shoes = await c.listShoes({ limit: 1, offset: 0 });++ equal(shoes.length, 1);++ const total = await c.countShoes({ brand: 'Nike' });++ equal(total, 1);++ equal((await c.countShoes({ size: '10.5' })), 1);++ equal((await c.countShoes({ condition: 'used' })), 0);++ const facets = await c.facets();++ equal(facets.models, 1);++ equal(facets.offers, 2);++ assert(facets.brands.some((b) => b.value === 'Nike'), 'brand facet');++ assert(facets.sizes.some((s) => s.value === '10.5'), 'size facet');++ });++++ await check('priceFor: the lowest all-in for a size', async () => {++ const model = await c.modelBySlug('nike-air-max-90');++ const any = await c.priceFor(model.id);++ equal(any.price, 70);++ const ten = await c.priceFor(model.id, { size: '10.5' });++ equal(ten.price, 105.99);++ const missing = await c.priceFor(model.id, { size: '4' });++ equal(missing.price, null);++ });++++ await check('drops: upsert, list, one by id, stable slug', async () => {++ const d1 = await c.upsertDrop({++ brand: 'Nike', model: 'Air Max 90', colourway: 'Medium Olive', releaseAt: '2026-11-04T15:00:00Z',++ price: 130, sizes: ['9', '10.5'], sourceName: 'Nike SNKRS', sourceUrl: 'https://www.nike.com/launch',++ sourceKey: 'air-max-90|2026-11-04', fetchedAt: '2026-10-31T20:00:00Z',++ });++ assert(d1, 'drop written');++ const again = await c.upsertDrop({++ brand: 'Nike', model: 'Air Max 90', colourway: 'Medium Olive', releaseAt: '2026-11-04T15:00:00Z',++ price: 130, sizes: ['9', '10.5'], sourceName: 'Nike SNKRS', sourceUrl: 'https://www.nike.com/launch',++ sourceKey: 'air-max-90|2026-11-04', fetchedAt: '2026-10-31T21:00:00Z',++ });++ equal(again.id, d1.id, 'the source key updates the same drop');++ const list = await c.listDrops({ from: '2026-11-01T00:00:00Z' });++ equal(list.length, 1);++ equal(list[0].sizes, ['9', '10.5']);++ equal((await c.dropById(d1.id)).model, 'Air Max 90');++ equal(await c.upsertDrop({ brand: 'Nike', model: 'No URL', releaseAt: '2026-11-04T15:00:00Z', sourceName: 'x', sourceKey: 'k' }), null);++ });++++ await check('facts: only cited, only the four kinds', async () => {++ const model = await c.modelBySlug('nike-air-max-90');++ const fact = await c.addFact({ modelId: model.id, kind: 'specs', label: 'Weight', value: '12.1 oz', sourceName: 'Nike', sourceUrl: 'https://www.nike.com/air-max-90', fetchedAt: '2026-10-31T20:00:00Z' });++ assert(fact, 'fact written');++ equal(await c.addFact({ modelId: model.id, kind: 'guess', label: 'x', value: 'y', sourceName: 'z', sourceUrl: 'https://example.com' }), null);++ equal(await c.addFact({ modelId: model.id, kind: 'fit', label: 'x', value: 'y', sourceName: 'z', sourceUrl: 'not a url' }), null);++ const facts = await c.factsOf(model.id);++ equal(facts.length, 1);++ equal(facts[0].source_url, 'https://www.nike.com/air-max-90');++ });++++ await check('watches: a person’s own, newest first', async () => {++ clock += 1000;++ await c.createWatch({ subject: user, kind: 'price', modelId: (await c.modelBySlug('nike-air-max-90')).id, slug: 'nike-air-max-90', size: '10.5', threshold: 90, remoteId: 'wch_1', status: 'active' });++ clock += 1000;++ await c.createWatch({ subject: user, kind: 'link', sourceUrl: 'https://www.ebay.com/itm/1', status: 'not_connected', note: 'no token' });++ const list = await c.listWatches(user);++ equal(list.length, 2);++ equal(list[0].kind, 'link');++ equal(list[1].threshold, 90);++ equal((await c.listWatches('usr_01J00000000000000000000BB')).length, 0);++ });++++ await check('job runs: start, finish and the last run per job', async () => {++ const id = await c.startJobRun({ job: 'offers', sourceName: 'OpenVibe.Deals', sourceUrl: 'https://openvibe.deals/api/v1/offers' });++ await c.finishJobRun(id, { ok: true, counts: { offers: 2 } });++ const last = await c.lastJobRuns();++ equal(last.length, 1);++ equal(last[0].job, 'offers');++ equal(last[0].ok, true);++ equal(last[0].counts.offers, 2);++ });++++ await check('stale is computed from the check time, not frozen at import', async () => {++ const model = await c.modelBySlug('nike-air-max-90');++ await s.db.exec("UPDATE shoe_offers SET stale = false, checked_at = '2026-10-25T12:00:00Z' WHERE model_id = $1", [model.id]);++ const offers = await c.offersOf(model.id);++ assert(offers.length && offers.every((o) => o.stale), 'every old offer reads stale');++ const listed = await c.listShoes({ brand: 'Nike' });++ equal(listed[0].stale_count, 2);++ equal((await c.facets()).stale_offers, 2);++ });++++ await check('counts: the rows the seed report prints', async () => {++ const counts = await c.counts();++ equal(counts.models, 1);++ equal(counts.offers, 2);++ equal(counts.snapshots, 3);++ equal(counts.drops, 1);++ equal(counts.watches, 2);++ equal(counts.facts, 1);++ });++ } finally {++ await test.close();++ }++ done();++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,51 @@++'use strict';++++/**++ * The release-page parser (server/domain/drops.js). It never invents a date: a release with no date or no model is++ * not returned, a launch payload's colourway is the quoted colour, and the size grid is the page's own.++ */++const { check, done, assert, equal } = require('./helpers/check');++const drops = require('../server/domain/drops');++const { LAUNCH_HTML, RSS } = require('./helpers/upstreams');++++(async () => {++ await check('parseLaunchCalendar: dated releases with model, colourway, price and sizes', () => {++ const out = drops.parseLaunchCalendar(LAUNCH_HTML, { sourceName: 'Nike SNKRS', sourceUrl: 'https://www.nike.com/launch', fetchedAt: '2026-10-31T22:00:00Z' });++ equal(out.length, 2);++ equal(out[0].brand, 'Nike');++ equal(out[0].model, 'Air Max 90');++ equal(out[0].colourway, 'Medium Olive');++ equal(out[0].price, 130);++ equal(out[0].releaseAt, '2026-11-04T15:00:00.000Z');++ equal(out[0].sizes, ['10.5', '9']);++ equal(out[0].sourceName, 'Nike SNKRS');++ equal(out[0].fetchedAt, '2026-10-31T22:00:00Z');++ equal(out[1].brand, 'Jordan');++ equal(out[1].model, '6 Retro');++ });++++ await check('parseLaunchCalendar: no commerceStartDate, no release', () => {++ equal(drops.parseLaunchCalendar('<html>nothing here</html>', { sourceName: 'x', sourceUrl: 'https://example.com' }), []);++ equal(drops.parseLaunchCalendar('{"commerceStartDate":"not a date","title":"Air Max 90"}', { sourceName: 'x', sourceUrl: 'https://example.com' }), []);++ equal(drops.parseLaunchCalendar('{"commerceStartDate":"2026-11-04T15:00:00Z"}', { sourceName: 'x', sourceUrl: 'https://example.com' }), []);++ });++++ await check('parseRss: dated items only, with their link as the source', () => {++ const out = drops.parseRss(RSS, { sourceName: 'Example releases', sourceUrl: 'https://example.com/feed' });++ equal(out.length, 1);++ equal(out[0].brand, 'Puma');++ equal(out[0].model, 'Suede Classic');++ equal(out[0].sourceUrl, 'https://example.com/releases/puma-suede');++ equal(out[0].releaseAt, '2026-11-11T10:00:00.000Z');++ });++++ await check('field: reads a quoted, numeric, boolean or null field', () => {++ equal(drops.field('{"a":"x\\"y","b":12.5,"c":true,"d":null}', 'a'), 'x"y');++ equal(drops.field('{"b":12.5}', 'b'), 12.5);++ equal(drops.field('{"c":true}', 'c'), true);++ equal(drops.field('{"d":null}', 'd'), null);++ equal(drops.field('{}', 'e'), null);++ });++++ done();++})();nnew file mode 100644@@@ -0,0 +1,113 @@++'use strict';++// Many requests per test file: the per-address limits would answer 429 part-way.++process.env.SHOES_RATE_LIMIT_PER_MIN = process.env.SHOES_RATE_LIMIT_PER_MIN || '100000';++process.env.SHOES_API_RATE_LIMIT_PER_MIN = process.env.SHOES_API_RATE_LIMIT_PER_MIN || '100000';++process.env.SHOES_LIMITS_MINUTE = process.env.SHOES_LIMITS_MINUTE || '100000';++process.env.SHOES_LIMITS_HOUR = process.env.SHOES_LIMITS_HOUR || '100000';++/**++ * Boots OpenVibe.Shoes on a temp database with a mock Network (test/helpers/mocks.js) and in-process public++ * sources and sibling services (test/helpers/upstreams.js), captures log lines, and returns a small HTTP client.++ * Every test file gets its own.++ *++ * const t = await boot();++ * t.get(path, { as: user, bearer, json, form, headers, method, cookie, origin })++ * t.signIn(user) → cookie header value (shoes_at = a Network user token, as after /auth/callback)++ * t.logs(), t.dbDump()++ *++ * `urlCheck` is a no-op by default so a pasted link does not need DNS in a test; production uses++ * openvibe-shared/egress. Pass `urlCheck` to test the refusal.++ */++const http = require('http');++const { startNetwork } = require('./mocks');++const { startUpstreams } = require('./upstreams');++++const captured = [];++for (const m of ['log', 'info', 'warn', 'error']) {++ const orig = console[m].bind(console);++ console[m] = (...a) => { captured.push(a.map(String).join(' ')); if (process.env.VERBOSE) orig(...a); };++}++++async function boot(opts = {}) {++ const network = await startNetwork(opts.network || {});++ const upstreams = await startUpstreams(opts.upstreams || {});++ const env = {++ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://open.shoes', TRUST_PROXY: '1',++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,++ OV_OAUTH_CLIENT_ID: 'shoes', OV_OAUTH_CLIENT_SECRET: 'shoes-secret', COOKIE_SECURE: 'false',++ SHOES_DRILL: '1',++ SHOES_EVENTS_URL: network.url,++ SHOES_DEALS_URL: upstreams.url,++ SHOES_DROPS_URL: `${upstreams.url}/launch`,++ SHOES_DROPS_SOURCE_NAME: 'Nike SNKRS launch calendar',++ SHOES_DROP_FEEDS: JSON.stringify([{ url: `${upstreams.url}/feed`, name: 'Example releases', type: 'rss' }]),++ SHOES_WATCH_INTERNAL_URL: upstreams.url, SHOES_WATCH_TOKEN: 'watch-test-token',++ SHOES_TODAY_INTERNAL_URL: upstreams.url,++ SHOES_EBAY_CLIENT_ID: 'ebay-id', SHOES_EBAY_CLIENT_SECRET: 'ebay-secret', SHOES_EBAY_API_URL: upstreams.url,++ SHOES_UPSTREAM_TIMEOUT_MS: '4000',++ ...(opts.env || {}),++ };++ for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];++ const configLib = require('../../server/config');++ const { createApp } = require('../../server/app');++ const log = { log: (...a) => captured.push(a.map(String).join(' ')), warn: (...a) => captured.push(a.map(String).join(' ')), error: (...a) => captured.push(a.map(String).join(' ')), info: (...a) => captured.push(a.map(String).join(' ')) };++++ const config = configLib.load(env);++ const { createStore } = require('../../server/db');++ const testdb = await require('./db').testDb();++ const store = createStore(testdb.db, { now: opts.now });++ const built = await createApp({++ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend,++ urlCheck: opts.urlCheck || (async () => {}),++ });++ await built.ctx.keys.ensure();++ const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++++ const signIn = (user) => `shoes_at=${network.userToken(user)}`;++++ async function get(p, o = {}) {++ const headers = { ...(o.headers || {}) };++ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');++ if (o.cookie) headers.cookie = o.cookie;++ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;++ let body = o.body;++ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }++ if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }++ const method = o.method || (body ? 'POST' : 'GET');++ if (o.as && method !== 'GET' && method !== 'HEAD' && !headers.origin && !headers['sec-fetch-site']) headers.origin = config.baseUrl;++ if (o.origin) headers.origin = o.origin;++ const res = await fetch(base + p, { method, headers, body, redirect: 'manual' });++ const buf = Buffer.from(await res.arrayBuffer());++ const text = buf.toString('utf8');++ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };++ }++++ async function dbDump() {++ const db = built.ctx.s.db;++ const tables = (await db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()")).map((r) => r.name);++ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.many(`SELECT * FROM "${tname}"`))))).join('\n');++ }++++ const t = {++ base, network, upstreams, config, ctx: built.ctx, get, signIn, dbDump,++ logs: () => captured.join('\n'),++ async close() {++ built.ctx.jobs.stop();++ await new Promise((r) => server.close(r));++ built.ctx.keys.client.stop();++ await built.ctx.telemetry.stop().catch(() => {});++ await testdb.close();++ await network.close();++ await upstreams.close();++ },++ };++ return t;++}++++let failures = 0;++async function check(name, fn) {++ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }++}++function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }++++module.exports = { boot, check, done };nnew file mode 100644@@@ -0,0 +1,10 @@++'use strict';++++let failures = 0;++async function check(name, fn) {++ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }++}++function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }++function assert(cond, message) { if (!cond) throw new Error(message || 'assertion failed'); }++function equal(a, b, message) { if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(`${message || 'not equal'}: ${JSON.stringify(a)} !== ${JSON.stringify(b)}`); }++module.exports = { check, done, assert, equal };nnew file mode 100644@@@ -0,0 +1,11 @@++'use strict';++/**++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with++ * SHOES_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.++ */++const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');++const { MIGRATIONS } = require('../../server/db');++++const testDb = ({ store = process.env.SHOES_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'shoes', max });++++module.exports = { testDb, pgAvailable };nnew file mode 100644@@@ -0,0 +1,137 @@++'use strict';++/**++ * An in-process stand-in for OpenVibe.Network with a real RS256 key pair.++ *++ * Network JWKS; /oauth/token (authorization_code + PKCE S256 for client `shoes`, refresh_token,++ * client_credentials); /oauth/revoke; GET /api/v1/me/follows (not used by Shoes, kept for parity);++ * POST /internal/identity/resolve-batch; POST /internal/notifications/push.++ *++ * Every request is recorded in `.requests`, token exchanges in `.tokenRequests`.++ */++const http = require('http');++const crypto = require('crypto');++const { serviceAuth, ids } = require('openvibe-contracts');++++function listen(handler) {++ return new Promise((resolve) => {++ const sockets = new Set();++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks);++ const json = (status, obj, headers = {}) => { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); };++ Promise.resolve(handler(req, raw, json, res)).catch((err) => { json(500, { error: String(err && err.message) }); });++ });++ });++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });++ server.listen(0, '127.0.0.1', () => resolve({++ server, url: `http://127.0.0.1:${server.address().port}`,++ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),++ }));++ });++}++++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');++function signJwt(claims, privateKey) {++ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}`;++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;++}++++async function startNetwork() {++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });++ const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], down: false };++ let issuer = null;++ let nextUserId = 1;++++ const problem = (json, status, code, detail) => json(status, { type: `https://openvibe.network/problems/${code}`, title: 'Error', status, code, detail, error: detail, request_id: `req_${crypto.randomBytes(6).toString('hex')}` }, { 'Content-Type': 'application/problem+json' });++++ function addUser(username, { role = 'user' } = {}) {++ const u = { id: nextUserId++, subject: ids.newId('user'), username, display_name: username, role };++ st.users.set(u.id, u); st.byUsername.set(username, u);++ return u;++ }++ function userToken(u, { ttl = 3600, iat = null } = {}) {++ const now = iat != null ? iat : Math.floor(Date.now() / 1000);++ return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);++ }++ function userFrom(req) {++ const h = String(req.headers.authorization || '');++ if (!h.startsWith('Bearer ')) return null;++ const parts = h.slice(7).split('.');++ if (parts.length !== 3) return null;++ const ok = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicPem, Buffer.from(parts[2], 'base64url'));++ if (!ok) return null;++ const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));++ if (c.exp * 1000 < Date.now()) return 'expired';++ return st.users.get(c.sub) || null;++ }++++ const srv = await listen(async (req, raw, json) => {++ const url = new URL(req.url, 'http://x');++ st.requests.push({ method: req.method, path: url.pathname, query: url.search, headers: req.headers, body: raw.toString('utf8') });++ if (st.down) return problem(json, 503, 'network.down', 'Network is restarting');++++ if (url.pathname === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });++ if (url.pathname === '/.well-known/openvibe') return json(200, { name: 'OpenVibe', issuer, contracts: { version: require('openvibe-contracts/package.json').version }, services: [] });++ if (url.pathname === '/oauth/revoke') return json(200, {});++ if (url.pathname === '/oauth/token' && req.method === 'POST') {++ const ct = String(req.headers['content-type'] || '');++ const body = ct.includes('json') ? JSON.parse(raw.toString('utf8') || '{}') : Object.fromEntries(new URLSearchParams(raw.toString('utf8')));++ st.tokenRequests.push({ grant_type: body.grant_type, client_id: body.client_id, audience: body.audience, scope: body.scope });++ if (body.client_id !== 'shoes' || body.client_secret !== 'shoes-secret') return json(401, { error: 'invalid_client', error_description: 'bad client secret' });++ if (body.grant_type === 'authorization_code') {++ const c = st.codes.get(body.code);++ if (!c) return json(400, { error: 'invalid_grant', error_description: 'unknown code' });++ st.codes.delete(body.code);++ const challenge = crypto.createHash('sha256').update(String(body.code_verifier || '')).digest('base64url');++ if (challenge !== c.challenge) return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });++ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;++ st.refresh.set(rt, c.user);++ return json(200, { access_token: userToken(c.user), refresh_token: rt, token_type: 'Bearer' });++ }++ if (body.grant_type === 'refresh_token') {++ const user = st.refresh.get(body.refresh_token);++ if (!user) return json(400, { error: 'invalid_grant', error_description: 'refresh token rejected' });++ st.refresh.delete(body.refresh_token);++ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;++ st.refresh.set(rt, user);++ return json(200, { access_token: userToken(user), refresh_token: rt, token_type: 'Bearer' });++ }++ if (body.grant_type === 'client_credentials') {++ const now = Math.floor(Date.now() / 1000);++ const caps = String(body.scope || '').split(/\s+/).filter(Boolean);++ return json(200, {++ access_token: serviceAuth.signServiceToken({ iss: issuer, sub: 'svc:shoes', actor_type: 'service', aud: [body.audience || 'openvibe.network'], cap: caps, iat: now, exp: now + 300, jti: crypto.randomUUID() }, privatePem),++ token_type: 'Bearer', expires_in: 300, scope: caps.join(' '),++ });++ }++ return json(400, { error: 'unsupported_grant_type' });++ }++++ if (url.pathname === '/api/v1/me/follows' && req.method === 'GET') {++ const u = userFrom(req);++ if (u === 'expired') return problem(json, 401, 'auth.expired', 'expired');++ if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');++ return json(200, { items: [], next_cursor: null });++ }++++ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') return json(200, { results: {} });++ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });++++ return json(404, { error: 'not found' });++ });++ issuer = srv.url;++++ return {++ url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,++ addUser, userToken, signJwt: (claims) => signJwt(claims, privatePem), issuer: () => issuer,++ issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },++ setDown(v) { st.down = v; },++ close: srv.close,++ };++}++++module.exports = { startNetwork, signJwt };nnew file mode 100644@@@ -0,0 +1,162 @@++'use strict';++++/**++ * In-process stand-ins for the public sources and the sibling services Shoes uses:++ *++ * OpenVibe.Deals GET /api/v1/offers?sort=new&page=1 (the fixture below; page 2 is empty)++ * GET /api/v1/products/:slug (brand + name)++ * OpenVibe.Watch POST /api/v1/watches (records the body; answers { watch })++ * OpenVibe.Today POST /api/v1/events (records the body; answers { event })++ * a release page GET /launch (the Nike SNKRS JSON payload) and GET /feed (plain RSS)++ * eBay POST /identity/v1/oauth2/token and GET /buy/browse/v1/item_summary/search++ *++ * The fixtures are chosen so the classifier, the size parser and the all-in price all have something real to do.++ */++const http = require('http');++++const DAY = '2026-10-31T20:00:00.000Z';++++const DEALS = [++ {++ id: 'dof_test_airmax', slug: 'air-max-90-white',++ title: 'Nike Air Max 90 Running Shoe - White', link: 'https://www.zappos.com/p/nike-air-max-90/product/1/color/2',++ store: { domain: 'zappos.com', name: 'Zappos' }, product: { slug: 'air-max-90', name: 'Air Max 90' },++ latest_observation: { price: '89.95', list_price: '130.00', shipping: '0.00', condition: 'new', currency: 'USD', observed_at: DAY },++ },++ {++ id: 'dof_test_jordan', slug: 'air-jordan-1-mid',++ title: 'Air Jordan 1 Mid Sneaker', link: 'https://www.footlocker.com/product/air-jordan-1-mid',++ store: { domain: 'footlocker.com', name: 'Foot Locker' }, product: { slug: 'air-jordan-1-mid', name: 'Air Jordan 1 Mid' },++ latest_observation: { price: '120.00', list_price: null, shipping: '5.99', condition: 'new', currency: 'USD', observed_at: DAY },++ },++ {++ id: 'dof_test_samba', slug: 'adidas-samba-og-size-10-5',++ title: 'Adidas Samba OG Shoes Size 10.5', link: 'https://www.adidas.com/us/samba-og',++ store: { domain: 'adidas.com', name: 'adidas' }, product: { slug: 'samba-og', name: 'Samba OG' },++ latest_observation: { price: '100.00', list_price: null, shipping: null, condition: 'new', currency: 'USD', observed_at: DAY },++ },++ {++ id: 'dof_test_joggers', slug: 'rival-fleece-joggers',++ title: 'Under Armour Rival Fleece Joggers', link: 'https://www.zappos.com/p/under-armour-joggers',++ store: { domain: 'zappos.com', name: 'Zappos' }, product: { slug: 'rival-fleece-joggers', name: 'Rival Fleece Joggers' },++ latest_observation: { price: '40.00', list_price: null, shipping: null, condition: null, currency: 'USD', observed_at: DAY },++ },++ {++ id: 'dof_test_990', slug: 'new-balance-990v6',++ title: 'New Balance 990v6 Running Shoe', link: 'https://www.newbalance.com/pd/990v6',++ store: { domain: 'newbalance.com', name: 'New Balance' }, product: { slug: '990v6', name: '990v6' },++ latest_observation: { price: '149.99', list_price: '199.99', shipping: '0.00', condition: 'new', currency: 'USD', observed_at: DAY },++ },++];++++const PRODUCTS = {++ 'air-max-90': { slug: 'air-max-90', name: 'Air Max 90', brand: 'Nike' },++ 'air-jordan-1-mid': { slug: 'air-jordan-1-mid', name: 'Air Jordan 1 Mid', brand: 'Jordan' },++ 'samba-og': { slug: 'samba-og', name: 'Samba OG', brand: 'Adidas' },++ 'rival-fleece-joggers': { slug: 'rival-fleece-joggers', name: 'Rival Fleece Joggers', brand: 'Under Armour' },++ '990v6': { slug: '990v6', name: '990v6', brand: 'New Balance' },++};++++const LAUNCH_HTML = (() => {++ const payload = JSON.stringify({++ products: {++ data: {++ items: {++ a: { commerceStartDate: '2026-11-04T15:00:00.000Z', title: 'Air Max 90 "Medium Olive"', subtitle: "Men's Shoes", fullPrice: 130, styleColor: 'IV4504-201', skus: [{ nike_size: '10.5' }, { nike_size: '9' }] },++ b: { commerceStartDate: '2026-11-07T15:00:00.000Z', title: 'Air Jordan 6 Retro "White and Black"', subtitle: "Men's Shoes", fullPrice: 215, styleColor: 'CT8529-105', skus: [{ nike_size: '9.5' }] },++ },++ },++ },++ });++ // The page carries the JSON in a JavaScript string literal, exactly as Nike's launch page does.++ return `<!doctype html><html><body><script>window.__INITIAL_STATE__ = ${JSON.stringify(payload)};</script></body></html>`;++})();++++const RSS = `<?xml version="1.0"?><rss version="2.0"><channel><title>Example releases</title>++<item><title>Puma Suede Classic Sneaker</title><link>https://example.com/releases/puma-suede</link><pubDate>Wed, 11 Nov 2026 10:00:00 GMT</pubDate></item>++<item><title>No date here</title><link>https://example.com/releases/none</link></item>++</channel></rss>`;++++const EBAY_ITEMS = [++ {++ itemId: 'v1|123|0', title: 'Nike Pegasus 41 Running Shoes Size 10.5', itemWebUrl: 'https://www.ebay.com/itm/123',++ price: { value: '79.99', currency: 'USD' }, shippingOptions: [{ shippingCost: { value: '4.99', currency: 'USD' } }],++ condition: 'New with box', seller: { username: 'runner' },++ },++ {++ itemId: 'v1|124|0', title: 'ASICS Gel-Kayano 31', itemWebUrl: 'https://www.ebay.com/itm/124',++ price: { value: '120.00', currency: 'USD' }, shippingOptions: [], condition: 'Pre-owned', seller: { username: 'shoeguy' },++ },++];++++function listen(handler) {++ return new Promise((resolve) => {++ const sockets = new Set();++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks).toString('utf8');++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++ Promise.resolve(handler(req, raw, json, res)).catch((err) => json(500, { error: String(err && err.message) }));++ });++ });++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });++ server.listen(0, '127.0.0.1', () => resolve({++ server, url: `http://127.0.0.1:${server.address().port}`,++ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),++ }));++ });++}++++async function startUpstreams() {++ const state = { requests: [], watchRequests: [], todayRequests: [], offers: DEALS.map((o) => JSON.parse(JSON.stringify(o))), ebayItems: EBAY_ITEMS.map((i) => JSON.parse(JSON.stringify(i))) };++++ const srv = await listen(async (req, raw, json, res) => {++ const url = new URL(req.url, 'http://x');++ const p = url.pathname;++ state.requests.push({ method: req.method, path: p, query: Object.fromEntries(url.searchParams), body: raw });++++ if (p === '/api/v1/offers') {++ const page = Number(url.searchParams.get('page') || 1);++ return json(200, { sort: 'new', page, total: state.offers.length, offers: page === 1 ? state.offers : [] });++ }++ const product = p.match(/^\/api\/v1\/products\/([^/]+)$/);++ if (product) {++ const found = PRODUCTS[decodeURIComponent(product[1])];++ return found ? json(200, { product: found, offers: [] }) : json(404, { error: 'product.not_found' });++ }++ if (p === '/api/v1/watches' && req.method === 'POST') {++ let body = {};++ try { body = JSON.parse(raw || '{}'); } catch { body = {}; }++ state.watchRequests.push({ body, headers: { ...req.headers } });++ return json(201, { watch: { id: `wch_${String(state.watchRequests.length).padStart(26, '0')}`, ...body } });++ }++ if (p === '/api/v1/watches' && req.method === 'GET') return json(200, { watches: [] });++ if (p === '/api/v1/events' && req.method === 'POST') {++ let body = {};++ try { body = JSON.parse(raw || '{}'); } catch { body = {}; }++ state.todayRequests.push({ body, headers: { ...req.headers } });++ return json(201, { event: { id: `evt_${String(state.todayRequests.length).padStart(26, '0')}`, ...body } });++ }++ if (p === '/launch') {++ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });++ return res.end(LAUNCH_HTML);++ }++ if (p === '/feed') {++ res.writeHead(200, { 'Content-Type': 'application/rss+xml' });++ return res.end(RSS);++ }++ if (p === '/identity/v1/oauth2/token') return json(200, { access_token: 'ebay-test-token', token_type: 'Bearer', expires_in: 3600 });++ if (p === '/buy/browse/v1/item_summary/search') return json(200, { itemSummaries: state.ebayItems });++ return json(404, { error: 'not found' });++ });++++ return {++ url: srv.url, state, close: srv.close,++ requestsFor(path) { return state.requests.filter((r) => r.path === path); },++ lastFor(path) { return state.requests.filter((r) => r.path === path).pop() || null; },++ };++}++++module.exports = { startUpstreams, DEALS, PRODUCTS, LAUNCH_HTML, RSS, EBAY_ITEMS };nnew file mode 100644@@@ -0,0 +1,115 @@++'use strict';++++/**++ * The jobs (server/jobs/*) against in-process public sources: the offers job keeps only footwear, gets the brand++ * from the product, writes one snapshot per offer per day and never duplicates on a second run; the drops job++ * keeps only dated releases; the eBay job uses the official Browse API and is off without keys.++ */++const { check, done, assert, equal } = require('./helpers/check');++const configLib = require('../server/config');++const { testDb } = require('./helpers/db');++const { createStore } = require('../server/db');++const { createCatalogue } = require('../server/catalogue');++const { createUpstreams } = require('../server/upstreams');++const { createOffersJob } = require('../server/jobs/offers');++const { createDropsJob } = require('../server/jobs/drops');++const { createEbayJob } = require('../server/jobs/ebay');++const { startUpstreams } = require('./helpers/upstreams');++++(async () => {++ const upstream = await startUpstreams();++ const test = await testDb();++ const config = configLib.load({++ NODE_ENV: 'test', BASE_URL: 'https://open.shoes',++ SHOES_DEALS_URL: upstream.url, SHOES_OFFERS_PAGES: '2', SHOES_PRODUCT_LOOKUPS: '20',++ SHOES_DROPS_URL: `${upstream.url}/launch`, SHOES_DROPS_SOURCE_NAME: 'Nike SNKRS launch calendar',++ SHOES_DROP_FEEDS: JSON.stringify([{ url: `${upstream.url}/feed`, name: 'Example releases', type: 'rss' }]),++ SHOES_EBAY_CLIENT_ID: 'ebay-id', SHOES_EBAY_CLIENT_SECRET: 'ebay-secret', SHOES_EBAY_API_URL: upstream.url,++ SHOES_EBAY_LIMIT: '10', SHOES_UPSTREAM_TIMEOUT_MS: '4000',++ });++ const s = createStore(test.db, { now: () => Date.parse('2026-10-31T22:00:00Z') });++ const catalogue = createCatalogue(s, { staleHours: 48 });++ const upstreams = createUpstreams({ config, log: { warn() {}, log() {} } });++ const offersJob = createOffersJob({ config, catalogue, upstreams, log: { log() {}, warn() {} } });++ const dropsJob = createDropsJob({ config, catalogue, upstreams, log: { log() {}, warn() {} } });++ const ebayJob = createEbayJob({ config, catalogue, upstreams, log: { log() {}, warn() {} } });++++ try {++ await check('offers: only footwear, brand from the product, one model each', async () => {++ const out = await offersJob.runOnce({ pages: 2 });++ equal(out.seen, 5);++ equal(out.candidates, 4, 'the joggers are not footwear');++ equal(out.offers, 4);++ const counts = await catalogue.counts();++ equal(counts.models, 4);++ equal(counts.offers, 4);++ equal(counts.snapshots, 4);++ const airmax = await catalogue.modelBySlug('nike-air-max-90');++ assert(airmax, 'Nike Air Max 90 is a model');++ const jordan = await catalogue.modelBySlug('jordan-1-mid');++ assert(jordan, 'Air Jordan 1 Mid is the model jordan-1-mid');++ equal(jordan.brand, 'Jordan');++ equal(jordan.model, '1 Mid');++ const samba = await catalogue.modelBySlug('adidas-samba-og');++ assert(samba, 'Samba OG model');++ const offers = await catalogue.offersOf(airmax.id);++ equal(offers.length, 1);++ equal(offers[0].all_in, 89.95);++ equal(offers[0].drop_from_list, 40.05);++ });++++ await check('offers: a second run does not duplicate an offer or snapshots', async () => {++ const out = await offersJob.runOnce({ pages: 2 });++ equal(out.offers, 4);++ const counts = await catalogue.counts();++ equal(counts.offers, 4);++ equal(counts.snapshots, 4);++ });++++ await check('drops: the launch calendar and the RSS feed, only dated releases', async () => {++ const out = await dropsJob.runOnce();++ equal(out.sources, 2);++ equal(out.releases, 3, 'two launch releases and one RSS item');++ equal(out.drops, 3);++ const drops = await catalogue.listDrops({ from: '2026-11-01T00:00:00Z' });++ equal(drops.length, 3);++ assert(drops.some((d) => d.brand === 'Puma' && d.model === 'Suede Classic'), 'the RSS item');++ assert(drops.every((d) => d.source_url.startsWith('http')), 'every drop cites its page');++ });++++ await check('ebay: the official Browse API, with the seller and its shipping', async () => {++ assert(upstreams.ebayConfigured(), 'configured');++ const out = await ebayJob.runOnce();++ assert(out.configured, 'ran');++ equal(out.items, 2);++ equal(out.offers, 2);++ const pegasus = (await catalogue.listShoes({ q: 'Pegasus' }))[0];++ assert(pegasus, 'the eBay Pegasus is in the catalogue');++ const offer = (await catalogue.offersOf(pegasus.id)).find((o) => o.source === 'ebay');++ assert(offer, 'the eBay offer exists');++ equal(offer.size, '10.5');++ equal(offer.all_in, 84.98);++ });++++ await check('ebay: off without a key pair, a plain note not an error', async () => {++ const cfg = configLib.load({ NODE_ENV: 'test', BASE_URL: 'https://open.shoes', SHOES_EBAY_CLIENT_ID: '', SHOES_EBAY_CLIENT_SECRET: '' });++ const off = createEbayJob({ config: cfg, catalogue, upstreams: createUpstreams({ config: cfg, log: { warn() {} } }), log: { log() {}, warn() {} } });++ const out = await off.runOnce();++ equal(out.configured, false);++ equal(out.offers, 0);++ assert(/off/.test(out.note), 'the note says it is off');++ });++++ await check('the job record says when each source was read', async () => {++ const last = await catalogue.lastJobRuns();++ const names = last.map((j) => j.job).sort();++ equal(names, ['drops', 'ebay', 'offers']);++ assert(last.every((j) => j.finished_at && j.ok), 'every job finished ok');++ assert(last.find((j) => j.job === 'offers').counts.offers === 4, 'the offers count is recorded');++ });++ } finally {++ await test.close();++ await upstream.close();++ }++ done();++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++++/** The migration: every table the service writes and the constraints that keep an invented row out. */++const { check, done, assert, equal } = require('./helpers/check');++const { testDb } = require('./helpers/db');++++(async () => {++ const test = await testDb();++ const db = test.db;++ try {++ const tables = (await db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() ORDER BY table_name")).map((r) => r.name);++ for (const t of ['shoe_models', 'shoe_offers', 'shoe_snapshots', 'shoe_drops', 'shoe_watches', 'shoe_facts', 'shoe_job_runs', 'account_data_events', 'telemetry_samples']) {++ assert(tables.includes(t), `missing table ${t}`);++ }++++ await check('a model, an offer and a snapshot accept the ids and amounts the code writes', async () => {++ await db.exec(`INSERT INTO shoe_models (id, slug, brand, model, name, first_seen_at) VALUES ('shm_01J000000000000000000000AA', 'nike-air-max-90', 'Nike', 'Air Max 90', 'Air Max 90', now())`);++ await db.exec(`INSERT INTO shoe_offers (id, model_id, source, source_id, store_domain, title, url, price_amount, shipping_amount, checked_at, size, condition)++ VALUES ('sho_01J000000000000000000000AA', 'shm_01J000000000000000000000AA', 'deals', 'd1', 'zappos.com', 'Nike Air Max 90', 'https://www.zappos.com/p/1', 89.95, 0, now(), '10.5', 'new')`);++ await db.exec(`INSERT INTO shoe_snapshots (id, offer_id, model_id, price_amount, checked_at, day) VALUES ('shs_01J000000000000000000000AA', 'sho_01J000000000000000000000AA', 'shm_01J000000000000000000000AA', 89.95, now(), current_date)`);++ equal(Number(await db.value('SELECT COUNT(*)::int FROM shoe_offers')), 1);++ });++++ await check('one snapshot per offer per day is a constraint, not a convention', async () => {++ let failed = false;++ try {++ await db.exec(`INSERT INTO shoe_snapshots (id, offer_id, model_id, price_amount, checked_at, day) VALUES ('shs_01J000000000000000000000AB', 'sho_01J000000000000000000000AA', 'shm_01J000000000000000000000AA', 88.00, now(), current_date)`);++ } catch { failed = true; }++ assert(failed, 'the unique (offer_id, day) refused a second point');++ });++++ await check('the id shapes, conditions and URLs are checked by the database', async () => {++ let failed = 0;++ for (const sql of [++ `INSERT INTO shoe_models (id, slug, brand, model, name, first_seen_at) VALUES ('bad', 'x', 'Nike', 'X', 'X', now())`,++ `INSERT INTO shoe_offers (id, model_id, source, source_id, store_domain, title, url, checked_at) VALUES ('sho_01J000000000000000000000AC', 'shm_01J000000000000000000000AA', 'deals', 'd2', 'x.com', 'X', 'not a url', now())`,++ `INSERT INTO shoe_offers (id, model_id, source, source_id, store_domain, title, url, condition, checked_at) VALUES ('sho_01J000000000000000000000AD', 'shm_01J000000000000000000000AA', 'deals', 'd3', 'x.com', 'X', 'https://x.com/1', 'refurbished', now())`,++ `INSERT INTO shoe_watches (id, subject, kind, status) VALUES ('shw_01J000000000000000000000AA', 'usr_01J000000000000000000000AA', 'guess', 'active')`,++ `INSERT INTO shoe_facts (id, model_id, kind, label, value, source_name, source_url, fetched_at) VALUES ('shf_01J000000000000000000000AA', 'shm_01J000000000000000000000AA', 'guess', 'x', 'y', 'z', 'https://example.com', now())`,++ ]) {++ try { await db.exec(sql); } catch { failed++; }++ }++ equal(failed, 5);++ });++++ await check('the same source id cannot make two offers', async () => {++ let failed = false;++ try {++ await db.exec(`INSERT INTO shoe_offers (id, model_id, source, source_id, store_domain, title, url, checked_at) VALUES ('sho_01J000000000000000000000AE', 'shm_01J000000000000000000000AA', 'deals', 'd1', 'x.com', 'X', 'https://x.com/1', now())`);++ } catch { failed = true; }++ assert(failed, 'the unique (source, source_id) refused it');++ });++ } finally {++ await test.close();++ }++ done();++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,158 @@++'use strict';++++/**++ * Every page: the room's front door (hero, proof, rails, scenes, explore, dev, FAQ, network), the search, one++ * shoe, the drops with a real "remind me" onto Today, a pasted link, the signed-in watches, the developers page,++ * the discovery artifacts and the 404.++ */++const { boot, check, done } = require('./helpers/boot');++const { assert, equal } = require('./helpers/check');++++(async () => {++ const t = await boot();++ try {++ await t.ctx.jobs.offers.runOnce({ pages: 2 });++ await t.ctx.jobs.drops.runOnce();++ const user = t.network.addUser('ada');++++ await check('GET /: sc.room with every section from real data', async () => {++ const res = await t.get('/');++ equal(res.status, 200);++ assert(res.headers.get('content-type').includes('text/html'), 'html');++ const h = res.text;++ assert(h.includes('class="ov-room sc-room"'), 'the room wrapper');++ assert(h.includes('data-room="shoes"'), 'the room id');++ assert(h.includes('Every shoe, every market, the right price'), 'the room line');++ assert(h.includes('The biggest price drops today'), 'the drops rail');++ assert(h.includes('Dropping this week'), 'the release rail');++ assert(h.includes('Popular models'), 'the popular rail');++ assert(h.includes('The biggest price drops today') && h.includes('ovk-card'), 'kit cards');++ assert(h.includes('A size-price curve') || h.includes('What a size costs'), 'the size scene');++ assert(h.includes('A drop reminder, on Today'), 'the Today scene');++ assert(h.includes('Paste a link, keep the price'), 'the link scene');++ assert(h.includes('For developers'), 'the dev section');++ assert(h.includes('Questions') || h.includes('sc-faq'), 'the FAQ');++ assert(h.includes('Part of OpenVibe'), 'the network section');++ assert(!h.includes('undefined'), 'no undefined in the page');++ assert(h.includes('OpenVibe.Shoes') && h.includes('open.shoes'), 'the name and the domain');++ });++++ await check('GET /shoes: search, facets and a real card', async () => {++ const res = await t.get('/shoes?brand=Nike');++ equal(res.status, 200);++ assert(res.text.includes('Nike Air Max 90'), 'the model');++ assert(res.text.includes('$89.95'), 'the all-in price');++ assert(res.text.includes('Brand'), 'the brand facet');++ assert(res.text.includes('1 model'), 'the count');++ });++++ await check('GET /shoe/:slug: offers, history, curve, facts and the watch form', async () => {++ const res = await t.get('/shoe/nike-air-max-90');++ equal(res.status, 200);++ const h = res.text;++ assert(h.includes('Nike Air Max 90'), 'the title');++ assert(h.includes('$89.95'), 'the all-in price');++ assert(h.includes('Current prices by store'), 'the offers');++ assert(h.includes('zappos.com'), 'the store');++ assert(h.includes('Price history'), 'the history');++ assert(h.includes('2026-10-31'), 'the snapshot day');++ assert(h.includes('Size-price curve'), 'the curve section');++ assert(h.includes('Not known yet'), 'the honest unknown');++ assert(h.includes('Release dates'), 'the cited releases section');++ assert(h.includes('Medium Olive'), 'the official release colourway');++ assert(h.includes('A price watch needs a person'), 'signed out prompt');++ assert(h.includes('90.00') || h.includes('from list'), 'the list-price drop');++ });++++ await check('GET /shoe/:slug signed in: the price-watch form', async () => {++ const res = await t.get('/shoe/nike-air-max-90', { as: user });++ equal(res.status, 200);++ assert(res.text.includes('Tell me when this drops below $X at my size'), 'the form');++ assert(res.text.includes('name="threshold"'), 'the price input');++ });++++ await check('GET /drops: the official calendar and the reminder', async () => {++ const res = await t.get('/drops');++ equal(res.status, 200);++ const h = res.text;++ assert(h.includes('Dropping this week and after'), 'the heading');++ assert(h.includes('Air Max 90'), 'a release');++ assert(h.includes('Nike SNKRS launch calendar'), 'the source');++ assert(h.includes('Never automated entries'), 'the raffle rule');++ assert(h.includes('Sign in to be reminded'), 'signed out prompt');++ });++++ await check('POST /drops/:id/remind: the drop goes on the person’s Today agenda', async () => {++ const drops = (await t.get('/api/v1/drops')).json().drops;++ const future = drops.find((d) => Date.parse(d.release_at) > Date.now());++ assert(future, 'a future release');++ const res = await t.get(`/drops/${encodeURIComponent(future.id)}/remind`, { as: user, method: 'POST' });++ equal(res.status, 302);++ assert(res.headers.get('location').startsWith('/drops?notice='), 'back to the calendar');++ equal(t.upstreams.state.todayRequests.length, 1);++ const sent = t.upstreams.state.todayRequests[0];++ equal(sent.body.kind, 'event');++ assert(sent.body.title.startsWith('Drop: '), 'the event title');++ equal(sent.body.starts_at, future.release_at);++ assert(sent.headers.authorization.startsWith('Bearer '), 'the person token');++ });++++ await check('GET /track and POST /track: a pasted link becomes a watch', async () => {++ const page = await t.get('/track', { as: user });++ equal(page.status, 200);++ assert(page.text.includes('name="url"'), 'the form');++ const res = await t.get('/track', { as: user, form: { url: 'https://www.ebay.com/itm/999' } });++ equal(res.status, 302);++ assert(res.headers.get('location').startsWith('/watches?notice='), 'to the watches');++ const watches = (await t.get('/api/v1/me/watches', { as: user })).json().watches;++ assert(watches.some((w) => w.kind === 'link' && w.source_url === 'https://www.ebay.com/itm/999'), 'the link watch');++ });++++ await check('GET /watches signed in: the watches, with the Watch note', async () => {++ const res = await t.get('/watches?notice=hello', { as: user });++ equal(res.status, 200);++ assert(res.text.includes('Watches'), 'the heading');++ assert(res.text.includes('hello'), 'the notice');++ assert(res.text.includes('https://www.ebay.com/itm/999'), 'the pasted link');++ const out = await t.get('/watches');++ assert(out.text.includes('Sign in to see your watches'), 'signed out');++ });++++ await check('GET /developers: the routes, sources and examples', async () => {++ const res = await t.get('/developers');++ equal(res.status, 200);++ const h = res.text;++ assert(h.includes('The Shoes API'), 'the heading');++ assert(h.includes('/api/v1/shoes'), 'the routes');++ assert(h.includes('/api/openapi.json'), 'OpenAPI');++ assert(h.includes('No scraping'), 'the source rule');++ assert(h.includes('OpenVibe.Watch'), 'the watch service');++ });++++ await check('discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt', async () => {++ const robots = await t.get('/robots.txt');++ equal(robots.status, 200);++ assert(robots.text.includes('Sitemap: https://open.shoes/sitemap.xml'), 'the sitemap');++ assert(robots.text.includes('/watches'), 'the private page is disallowed');++ const sitemap = await t.get('/sitemap.xml');++ equal(sitemap.status, 200);++ assert(sitemap.text.includes('/shoe/nike-air-max-90'), 'the model page');++ const llms = await t.get('/llms.txt');++ equal(llms.status, 200);++ assert(llms.text.includes('OpenVibe.Shoes'), 'the llms index');++ const full = await t.get('/llms-full.txt');++ equal(full.status, 200);++ assert(full.text.includes('Nike Air Max 90'), 'the full pages');++ });++++ await check('a missing page is 404 through the layout', async () => {++ const res = await t.get('/no-such-page');++ equal(res.status, 404);++ assert(res.text.includes('No such page'), 'the not-found page');++ equal((await t.get('/shoe/no-such-shoe')).status, 404);++ });++ } finally {++ await t.close();++ }++ done();++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,90 @@++'use strict';++++/**++ * The pure rules (server/domain/parse.js). These are the promises the pages make: a size is shown only when a++ * source names one, a shoe is footwear, the brand and model are not guessed, and an all-in price never invents++ * shipping.++ */++const { check, done, assert, equal } = require('./helpers/check');++const parse = require('../server/domain/parse');++++(async () => {++ await check('isShoe: footwear words and shoe-only models', () => {++ assert(parse.isShoe('Nike Air Max 90 Running Shoe - White'), 'running shoe');++ assert(parse.isShoe('Air Jordan 1 Mid Sneaker'), 'sneaker');++ assert(parse.isShoe('Nike Air Force 1 \'07'), 'a model only a shoe has, no footwear word');++ assert(parse.isShoe('Birkenstock Boston Clog'), 'clog');++ assert(!parse.isShoe('Under Armour Rival Fleece Joggers'), 'joggers are not shoes');++ assert(!parse.isShoe('Nike Dri-FIT T-Shirt'), 'a shirt with a shoe brand is not a shoe');++ assert(!parse.isShoe('The North Face Hoodie'), 'a hoodie is not a shoe');++ });++++ await check('brandOf: the source brand first, the title second', () => {++ equal(parse.brandOf('Air Max 90', 'Nike'), 'Nike');++ equal(parse.brandOf('air jordan 1 mid', null), 'Jordan', 'Air Jordan is the Jordan brand');++ equal(parse.brandOf('New Balance 990v6', null), 'New Balance');++ equal(parse.brandOf('ASICS Gel-Kayano 31', null), 'ASICS');++ equal(parse.brandOf('Samba OG', 'adidas'), 'Adidas', 'a known brand is canonical, not a second spelling');++ equal(parse.brandOf('Pickleball', 'SKECHERS'), 'Skechers');++ equal(parse.brandOf('A shoe with no brand', null), null);++ });++++ await check('modelOf: person words, the brand, the kind and a quoted colourway taken off', () => {++ equal(parse.modelOf("Women's Nike Air Max 90 Running Shoe", 'Nike'), 'Air Max 90');++ equal(parse.modelOf('Air Jordan 14 Retro "Forest Green"', 'Jordan'), '14 Retro');++ equal(parse.modelOf('Adidas Samba OG Shoes Size 10.5', 'Adidas'), 'Samba OG Size 10.5');++ equal(parse.modelOf('Air Max 90 - Men\'s', 'Nike'), 'Air Max 90');++ equal(parse.modelOf('990v6', 'New Balance'), '990v6');++ });++++ await check('colourwayOf: quoted in the title, or the source hint', () => {++ equal(parse.colourwayOf('Air Max 90 "Medium Olive"'), 'Medium Olive');++ equal(parse.colourwayOf('Air Max 90', 'White/Black'), 'White/Black');++ equal(parse.colourwayOf('Air Max 90'), null);++ });++++ await check('sizeOf and cleanSize: only a size the source names', () => {++ equal(parse.sizeOf('Nike Air Max 90 Size 10.5'), '10.5');++ equal(parse.sizeOf('Adidas Samba US 9'), '9');++ equal(parse.sizeOf('Nike Air Max 90'), null);++ equal(parse.sizeOf('Nike Air Max 90 - 10.5'), null, 'a bare number is not a size');++ equal(parse.cleanSize('10.5'), '10.5');++ equal(parse.cleanSize('10.5c'), '10.5C');++ equal(parse.cleanSize('5y'), '5Y');++ });++++ await check('conditionOf: new, used, deadstock or not stated', () => {++ equal(parse.conditionOf('New with box'), 'new');++ equal(parse.conditionOf('pre-owned'), 'used');++ equal(parse.conditionOf('Deadstock'), 'deadstock');++ equal(parse.conditionOf(''), null);++ equal(parse.conditionOf('refurbished'), null, 'never guess a condition');++ });++++ await check('amountOf and allIn: shipping only when stated', () => {++ equal(parse.amountOf('$1,299.00'), 1299);++ equal(parse.amountOf('89.95'), 89.95);++ equal(parse.amountOf(''), null);++ equal(parse.allIn(100, 5.99), { amount: 105.99, shippingKnown: true, shipping: 5.99 });++ equal(parse.allIn(100, null), { amount: 100, shippingKnown: false, shipping: null });++ equal(parse.allIn(null, 5), { amount: null, shippingKnown: false, shipping: null });++ });++++ await check('slugFor: the page path, bounded', () => {++ equal(parse.slugFor('Nike', 'Air Max 90'), 'nike-air-max-90');++ equal(parse.slugFor('New Balance', '990v6'), 'new-balance-990v6');++ assert(parse.slugFor('A'.repeat(80), 'B'.repeat(80)).length <= 80, 'slug bounded');++ assert(/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(parse.slugFor('Dr. Martens', '1460 Boot')), 'slug shape');++ });++++ await check('dayOf and isStale: UTC day and the stale window', () => {++ equal(parse.dayOf('2026-10-31T23:59:59Z'), '2026-10-31');++ equal(parse.dayOf('not a date'), null);++ const now = Date.parse('2026-10-31T12:00:00Z');++ assert(parse.isStale('2026-10-29T11:00:00Z', now, 48), '49 hours is stale');++ assert(!parse.isStale('2026-10-30T12:00:00Z', now, 48), '24 hours is fresh');++ assert(!parse.isStale('not a date', now, 48), 'an unknown time is not stale');++ });++++ done();++})();nnew file mode 100644@@@ -0,0 +1,20 @@++#!/usr/bin/env node++/**++ * Runs every test/*.test.js in its own process and fails if any fails. They use temp PGlite databases, an++ * in-process mock of OpenVibe.Network, and in-process Chat/Live/Video fixtures; none needs the network.++ *++ * npm test # everything++ * npm test -- importer # only files whose name contains one of the words++ * npm test -- --strict # a skipped test fails the run too++ */++'use strict';++const { run } = require('openvibe-shared/test-runner');++++// VERBOSE=1 also prints every file's output, then the summary again.++run({ dir: __dirname, timeoutMs: 90000, pad: 34, parallel: 1 }).then((r) => {++ if (process.env.VERBOSE) {++ for (const t of r.results) console.log(`\n── ${t.file} ──\n${t.output.trimEnd()}`);++ console.log(`\n${r.summary}`);++ }++ process.exit(r.exitCode);++}, (err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,150 @@++'use strict';++/**++ * Sign-in with PKCE S256 (client id shoes, cookies shoes_at/shoes_rt/shoes_oauth), the session-token rules (a++ * FedCM assertion or an app token is not a session), the open-redirect guard, truthful readiness, loopback++ * metrics, pages without JavaScript, and cross-site sign-out.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, check, done } = require('./helpers/boot');++const { sanitizeNext } = require('../server/auth/sso');++++(async () => {++ const t = await boot();++ const user = t.network.addUser('kim');++++ await check('login redirects to Network with state and an S256 code challenge, client id shoes', async () => {++ const r = await t.get('/auth/login?next=/shoe/nike-air-max-90');++ assert.strictEqual(r.status, 302);++ const loc = new URL(r.headers.get('location'));++ assert.strictEqual(loc.origin + loc.pathname, `${t.network.url}/oauth/authorize`);++ assert.strictEqual(loc.searchParams.get('client_id'), 'shoes');++ assert.strictEqual(loc.searchParams.get('code_challenge_method'), 'S256');++ assert.match(loc.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);++ assert.match(loc.searchParams.get('state'), /^[0-9a-f]{32}$/);++ assert.match(r.headers.get('set-cookie'), /shoes_oauth=.*Path=\/auth.*HttpOnly/i);++ });++++ await check('callback exchanges the code with the matching verifier and sets httpOnly session cookies', async () => {++ const login = await t.get('/auth/login?next=/shoe/nike-air-max-90');++ const loc = new URL(login.headers.get('location'));++ const flowCookie = login.headers.get('set-cookie').split(';')[0];++ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));++ const r = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });++ assert.strictEqual(r.status, 302, r.text);++ assert.strictEqual(r.headers.get('location'), '/shoe/nike-air-max-90');++ const set = r.headers.get('set-cookie');++ assert.match(set, /shoes_at=[^;]+;.*HttpOnly/i);++ assert.match(set, /shoes_rt=[^;]+;.*HttpOnly/i);++ assert.match(set, /shoes_oauth=;/);++ const at = set.match(/shoes_at=([^;]+)/)[1];++ const me = await t.get('/auth/me', { cookie: `shoes_at=${at}` });++ assert.strictEqual(me.json().user.username, 'kim');++ assert.ok(!me.text.includes(at), '/auth/me never returns the token');++ });++++ await check('a wrong state, a verifier that does not match, or no flow cookie is refused', async () => {++ const login = await t.get('/auth/login');++ const loc = new URL(login.headers.get('location'));++ const flowCookie = login.headers.get('set-cookie').split(';')[0];++ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));++ const bad = await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: flowCookie });++ assert.strictEqual(bad.status, 400);++ const other = t.network.issueCode(user, crypto.createHash('sha256').update('another-verifier').digest('base64url'));++ const mismatch = await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });++ assert.strictEqual(mismatch.status, 400);++ const noCookie = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`);++ assert.strictEqual(noCookie.status, 400);++ });++++ await check('next= only accepts same-site paths', async () => {++ const r = await t.get('/auth/login?next=//evil.example/x');++ const flow = decodeURIComponent(r.headers.get('set-cookie').match(/shoes_oauth=([^;]+)/)[1]);++ assert.strictEqual(JSON.parse(flow).next, '/');++ const config = { baseUrl: 'https://open.shoes' };++ assert.strictEqual(sanitizeNext('/shoe/nike-air-max-90?x=1', config), '/shoe/nike-air-max-90?x=1');++ assert.strictEqual(sanitizeNext('https://open.shoes/drops?y=1', config), '/drops?y=1');++ for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://open.shoes.evil.com/x']) assert.strictEqual(sanitizeNext(v, config), '/', v);++ });++++ await check('readiness is truthful: db required; JWKS, the OAuth client and Valkey optional', async () => {++ const r = await t.get('/api/ready');++ const b = r.json();++ assert.strictEqual(r.status, 200);++ assert.strictEqual(b.checks.db.status, 'ok');++ assert.strictEqual(b.checks.db.required, true);++ assert.strictEqual(b.checks.network_jwks.status, 'ok');++ assert.strictEqual(b.checks.network_jwks.required, false);++ assert.strictEqual(b.checks.oauth_client.status, 'ok');++ assert.strictEqual(b.checks.oauth_client.required, false);++ assert.ok('valkey' in b.checks);++ });++++ await check('/release.json and /metrics (loopback only)', async () => {++ const rel = await t.get('/release.json');++ assert.strictEqual(rel.status, 200);++ assert.strictEqual(rel.json().service, 'shoes');++ assert.deepStrictEqual(require('openvibe-contracts').validate('registry.release-manifest@1', rel.json()).errors, []);++ const m = await t.get('/metrics');++ assert.strictEqual(m.status, 200);++ assert.match(m.text, /http_requests_total|release_info/);++ const proxied = await t.get('/metrics', { headers: { 'x-forwarded-for': '203.0.113.9' } });++ assert.notStrictEqual(proxied.status, 200, 'a proxied request is not loopback');++ });++++ await check('public pages are complete without JavaScript', async () => {++ for (const p of ['/', '/shoes', '/drops', '/developers']) {++ const r = await t.get(p);++ assert.strictEqual(r.status, 200, p);++ assert.match(r.text, /<noscript>/);++ assert.match(r.text, /<footer id="ov-footer"/);++ assert.match(r.text, /<main id="main"/);++ }++ });++++ await check('the API answers /status and unknown routes are problem+json 404', async () => {++ const status = await t.get('/api/v1/status');++ assert.strictEqual(status.status, 200);++ assert.strictEqual(status.json().service, 'shoes');++ const gone = await t.get('/api/v1/nope');++ assert.strictEqual(gone.status, 404);++ assert.match(gone.headers.get('content-type'), /application\/problem\+json/);++ assert.strictEqual(gone.json().code, 'route.not_found');++ });++++ await check('a FedCM assertion and an app token are not sessions', async () => {++ const now = Math.floor(Date.now() / 1000);++ const fedcm = t.network.signJwt({ iss: t.network.issuer(), sub: user.id, id: user.id, subject_id: user.subject, username: user.username, display_name: user.username, nonce: null, typ: 'fedcm', jti: 'x', aud: 'https://tenant.openvibe.host', iat: now, exp: now + 300 });++ assert.strictEqual((await t.get('/auth/me', { cookie: `shoes_at=${fedcm}` })).status, 401);++ const page = await t.get('/', { cookie: `shoes_at=${fedcm}` });++ assert.ok(!page.text.includes('Signed in as <strong>kim'));++ const app = t.network.signJwt({ iss: t.network.issuer(), sub: 'app:app_01JABCDEFGHJKMNPQRSTVWXYZ0', actor_type: 'app', aud: ['openvibe.network'], cap: ['x.y.z'], subject_id: user.subject, iat: now, exp: now + 300 });++ assert.strictEqual((await t.get('/auth/me', { cookie: `shoes_at=${app}` })).status, 401);++ });++++ await check('/auth/me: a guest is signed out (200 { user: null }); a failing session cookie is 401', async () => {++ const guest = await t.get('/auth/me');++ assert.strictEqual(guest.status, 200);++ assert.deepStrictEqual(JSON.parse(guest.text), { user: null });++ assert.strictEqual(guest.headers.get('cache-control'), 'private, no-store');++ assert.strictEqual((await t.get('/auth/me', { cookie: 'shoes_at=garbage' })).status, 401);++ assert.strictEqual((await t.get('/auth/me', { cookie: 'shoes_rt=stale' })).status, 401);++ assert.strictEqual((await t.get('/auth/me', { as: user })).status, 200);++ });++++ await check('another site cannot sign a person out; this site can', async () => {++ const cross = await t.get('/auth/logout', { as: user, headers: { 'sec-fetch-site': 'cross-site' } });++ assert.strictEqual(cross.status, 200);++ assert.ok(!/shoes_at=;|shoes_at=deleted/i.test(String(cross.headers.get('set-cookie') || '')));++ assert.match(cross.text, /method="post"/);++ const crossPost = await t.get('/auth/logout', { as: user, method: 'POST', headers: { 'sec-fetch-site': 'cross-site' } });++ assert.strictEqual(crossPost.status, 403);++ const same = await t.get('/auth/logout?next=/shoes', { as: user, headers: { 'sec-fetch-site': 'same-origin' } });++ assert.strictEqual(same.status, 303);++ assert.strictEqual(same.headers.get('location'), '/shoes');++ assert.match(String(same.headers.get('set-cookie') || ''), /shoes_at=;/);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,48 @@++'use strict';++++/**++ * The page's small pure pieces: the release variants become one row, money is never a made-up zero, and a price++ * history with one point is still drawn as one point (no fabricated curve).++ */++const { check, done, assert, equal } = require('./helpers/check');++const views = require('../server/views');++++(async () => {++ await check('groupDrops: one row per model, colourway and release moment', () => {++ const base = { brand: 'Jordan', model: '6 Retro', colourway: 'White Infrared', release_at: '2026-11-07T15:00:00.000Z', source_name: 'Nike SNKRS', source_url: 'https://www.nike.com/launch' };++ const out = views.groupDrops([++ { ...base, id: 'a', price: 215, sizes: ['9'] },++ { ...base, id: 'b', price: 155, sizes: ['10', '9'] },++ { ...base, id: 'c', colourway: 'Green Screen', price: 235, sizes: [] },++ ]);++ equal(out.length, 2);++ equal(out[0].variants, 2);++ equal(out[0].price, 155, 'the lowest price of the variants');++ equal(out[0].sizes, ['9', '10']);++ equal(out[1].colourway, 'Green Screen');++ });++++ await check('money: a number, or a dash, never a zero', () => {++ equal(views.money(89.95), '$89.95');++ equal(views.money(1299), '$1,299.00');++ equal(views.money(null), '—');++ equal(views.money('not a number'), '—');++ });++++ await check('historyChart: one real point is a dot, no curve invented', () => {++ const one = views.historyChart([{ day: '2026-10-31', price: 89.95 }]);++ assert(one.includes('<svg'), 'the chart');++ assert(one.includes('2026-10-31 → 2026-10-31'), 'one day');++ assert(!one.includes('No snapshot yet'), 'a point is a point');++ assert(views.historyChart([]).includes('No snapshot yet'), 'nothing yet says so');++ });++++ await check('curveBars: an empty curve says not known yet', () => {++ assert(views.curveBars({ points: [], median: null }).includes('not stated'), 'the honest note');++ const bars = views.curveBars({ points: [{ size: '9', min_price: 90, vs_median_percent: -5 }], median: 95 });++ assert(bars.includes('sh-curve') && bars.includes('9'), 'a real bar');++ assert(bars.includes('−5.0%'), 'the comparison');++ });++++ done();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+# OpenVibe.Shoes — environment (production: /etc/openvibe/shoes.env). Names only; never commit values.
+
+NODE_ENV=production
+PORT=5180
+HOST=127.0.0.1
+BASE_URL=https://open.shoes
+TRUST_PROXY=2
+# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh: the runtime role through PgBouncer,
+# the owner on a direct connection (migrations), and Valkey (shared limit counters).
+# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
+DATABASE_URL=
+DATABASE_DIRECT_URL=
+VALKEY_URL=
+VALKEY_PREFIX=ov:shoes:
+# Per-address limits (server/app.js).
+#SHOES_API_RATE_LIMIT_PER_MIN=240
+#SHOES_RATE_LIMIT_PER_MIN=300
+
+# OpenVibe.Network: SSO (OAuth client `shoes`, PKCE S256) and its JWKS.
+OV_NETWORK_URL=https://openvibe.network
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+# OV_NETWORK_ISSUER=https://openvibe.network
+OV_OAUTH_CLIENT_ID=shoes
+OV_OAUTH_CLIENT_SECRET=
+# OV_OAUTH_REDIRECT_URI=https://open.shoes/auth/callback
+# SHOES_AUDIENCE=open.shoes
+# OV_SESSION_AUDIENCE=openvibe.network
+# SHOES_PGLITE_DIR=./data/pglite
+
+# OpenVibe.Events: account export/deletion deliveries to POST /internal/events. The secret signs a delivery
+# (comma-separated for rotation, 32+ characters each; unset makes the route answer 503).
+SHOES_EVENTS_SECRET=
+SHOES_EVENTS_URL=
+#SHOES_EVENTS_ENDPOINT=http://127.0.0.1:5180/internal/events
+#SHOES_EVENTS_INTERVAL_MS=5000
+
+# The sources the jobs read. OpenVibe.Deals' public API needs no key.
+SHOES_DEALS_URL=https://openvibe.deals
+# How many newest offer pages per run (25 offers a page; 20 pages = 500 offers).
+#SHOES_OFFERS_PAGES=20
+# How many product lookups per run (the public product record gives the brand).
+#SHOES_PRODUCT_LOOKUPS=300
+#SHOES_STALE_HOURS=48
+#SHOES_UPSTREAM_TIMEOUT_MS=15000
+# A polite gap between reads of the public source, in milliseconds (0 turns it off).
+#SHOES_REQUEST_GAP_MS=250
+
+# The official release calendar (a brand's or a store's own page), and optional RSS feeds as a JSON array:
+# SHOES_DROP_FEEDS=[{"url":"https://example.com/releases.rss","name":"Example releases","type":"rss"}]
+SHOES_DROPS_URL=https://www.nike.com/launch
+SHOES_DROPS_SOURCE_NAME=Nike SNKRS launch calendar
+
+# eBay's official Browse API: off unless both are set. Never commit the pair; production reads it from this file.
+SHOES_EBAY_CLIENT_ID=
+SHOES_EBAY_CLIENT_SECRET=
+#SHOES_EBAY_MARKETPLACE=EBAY_US
+#SHOES_EBAY_CATEGORY_ID=93427
+#SHOES_EBAY_LIMIT=50
+
+# OpenVibe.Watch: a service token with watch.watch.manage, and the person in X-OV-Subject. Without a token a watch
+# is saved on Shoes and marked "not connected" (a plain note, never an error).
+SHOES_WATCH_URL=https://openvibe.watch
+SHOES_WATCH_INTERNAL_URL=http://127.0.0.1:4730
+SHOES_WATCH_TOKEN=
+#SHOES_WATCH_EVERY_SEC=21600
+
+# OpenVibe.Today: the drops calendar's reminders (the person's own Network token is the Bearer).
+SHOES_TODAY_URL=https://openvibe.today
+SHOES_TODAY_INTERNAL_URL=http://127.0.0.1:5100
+
+# Job intervals (off under SHOES_DRILL=1).
+#SHOES_OFFERS_INTERVAL_MS=1800000
+#SHOES_DROPS_INTERVAL_MS=21600000
+#SHOES_EBAY_INTERVAL_MS=3600000
+
+# A restore drill starts no jobs and creates no subscriptions.
+#SHOES_DRILL=0
+
+# optional: defaults to true in production
+# COOKIE_SECURE=true
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ # install, node --check and npm test: the shared test job (Track Q).
+ test:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
+ with:
+ syntax-dirs: server test scripts
+ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
+ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.
+ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
+ # Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
+ security:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+node_modules/
+data/
+dist/
+.env
+.env.*
+!.env.example
+.npm-cache/
+*.log
+*.pem
+*.key
+!server/data/
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+# Changelog
+
+What changed in OpenVibe.Shoes, newest first.
+
+## 0.1.0 — 2026-10-31
+
+The first version: every shoe, every market, the right price.
+
+- **Models and offers.** `shoe_models` and `shoe_offers`: one page per model (`/shoe/<brand>-<model>`), every
+ store's offer with price, shipping, list price, condition, size and colourway when the source names them, the
+ source's own check time and a stale flag. The all-in price is price + shipping when shipping is stated.
+- **The offers job.** Every 30 minutes (off under `SHOES_DRILL`) it reads OpenVibe.Deals' public newest offers,
+ keeps the footwear (a footwear word or a model only a shoe has), reads each product's public record for its
+ brand, and writes the offers into Shoes' own catalogue. One snapshot per offer per day.
+- **The shoe page.** Current prices by store, the price history drawn from Shoes' own snapshots, the size-price
+ curve where sizes are known, colourways, and cited specs/fit/release/care — or "not known yet".
+- **The drops job.** The official release calendar (Nike SNKRS by default) and optional RSS feeds, parsed by a
+ pure parser that never invents a date; each release keeps its source page and read time.
+- **Watches.** A price watch at a size (`POST /api/v1/me/watches`) and a pasted listing link
+ (`POST /api/v1/me/track`) are created in OpenVibe.Watch with the person in `X-OV-Subject`; a pasted URL goes
+ through `openvibe-shared/egress` first. Without a service token the watch is saved and marked "not connected".
+- **Today.** A drop reminder goes on the person's own Today agenda with their Network token. Raffles are never
+ automated.
+- **The API.** Search with facets, one model with offers/history/curve/facts, the price a Watch polls, drops, the
+ person's watches and a pasted link, the source status, and an OpenAPI 3.1 document.
+- **The home.** `sc.room`: hero, live proof, the biggest price drops today, dropping this week, popular models,
+ three scenes (a size-price curve, a drop reminder on Today, a pasted Marketplace link), explore, developers,
+ questions, and the rest of OpenVibe.
+- **Account data.** A person's watches are exported and deleted with their account; the catalogue is the
+ network's public record.
+- **No affiliate links, no Marketplace scraping, no invented specs.**
new file mode 100644
new file mode 100644
@@ -0,0 +1,661 @@
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.
new file mode 100644
new file mode 100644
@@ -0,0 +1,160 @@
@@ -0,0 +1,160 @@
+# OpenVibe.Shoes
+
+> Every shoe, every market, the right price.
+
+**Status:** v1 written and tested (see [STATUS.json](STATUS.json)).
+**Domain:** `open.shoes` · **Port:** 5180 · **Service id:** `shoes` · **Env prefix:** `SHOES`
+**License:** AGPL-3.0-only (same as every OpenVibe service).
+
+OpenVibe.Shoes is the network's shoe resource: one page per model (`/shoe/<brand>-<model>`) with every store's
+offer in one all-in price, a price history from Shoes' own daily snapshots, the size-price curve where sizes are
+known, and cited specs, fit notes, release facts and care notes — or an honest "not known yet". A person can ask
+to be told when the model drops below a price at their size, or paste any listing link and have OpenVibe.Watch
+keep it. Official release dates are a drops calendar with "remind me" on OpenVibe.Today. Shoes never enters a
+raffle or a draw, never scrapes Facebook Marketplace, and carries no affiliate links.
+
+## What it reads
+
+| Source | What | Key |
+|---|---|---|
+| **OpenVibe.Deals** | the network's own offer aggregator: its public newest-offer pages and each product's public record (brand). No key. | no |
+| **eBay Browse API** | official item summaries, only when a developer key pair is set | `SHOES_EBAY_CLIENT_ID` + `SHOES_EBAY_CLIENT_SECRET` |
+| **A brand's or a store's own release page** | the official release calendar (Nike SNKRS by default), parsed by a pure parser that never invents a date | no |
+| **OpenVibe.Watch** | a watch for a person (service token; the person in `X-OV-Subject`) | `SHOES_WATCH_TOKEN` |
+| **OpenVibe.Today** | a drop reminder on the person's own agenda (their Network token) | the person's session |
+
+Every offer keeps its store, its currency, the price, the shipping the store stated, the condition, the size and
+colourway when the source names them, the source's own check time and a stale flag. Every price is **all-in**:
+price + shipping when shipping is stated. A missing key turns that part off with a plain note; it is never an
+error. No source is a scrape: Deals reads stores through the network's sources, eBay is the official API, and a
+pasted Marketplace link is handed to Watch, which applies its own fetch rules.
+
+## Pages
+
+| Route | What it shows |
+|---|---|
+| `/` | the room's front door: hero, live proof, the biggest price drops today, dropping this week, popular models, the three scenes (a size-price curve, a drop reminder on Today, a pasted link), explore, developers, questions |
+| `/shoes?q=&brand=&size=&condition=&sort=&page=` | the catalogue with facets; every offer all-in and dated |
+| `/shoe/:slug` | one model: offers by store, the daily price history (SVG from Shoes' own snapshots), the size-price curve, colourways, cited facts or "not known yet", and the watch form |
+| `/drops` | the official release calendar, grouped by day, with "remind me" (on Today) and the never-automated-entries note |
+| `/track` | paste a listing link; it becomes a Watch |
+| `/watches` | a signed-in person's watches (private, no-store) |
+| `/developers` | the API, sources and examples |
+| `/api/openapi.json` | the OpenAPI 3.1 document |
+
+All pages are server-rendered through `openvibe-shared/shell` and the room kit (`rooms.js`, `kit.card`,
+`kit.rail`, `sc.room`), with the room id `shoes` and the accent `#FF7E76` (dark) / `#B02A2D` (light). Every page is
+complete without JavaScript; forms are plain POSTs.
+
+## API
+
+Everything under `/api/v1`; errors are `application/problem+json` with a `code`. Public reads take no key.
+
+| Route | Answer |
+|---|---|
+| `GET /api/v1/shoes?q&brand&size&condition&store&sort&page&limit` | `{ shoes, facets, page, per_page, total, sort, filters }`; sorts `recent`, `drop`, `price`, `price_desc`, `popular`, `name` |
+| `GET /api/v1/shoes/:slug` | `{ shoe, offers, history, curve, facts, drops }` |
+| `GET /api/v1/shoes/:slug/price?size=&condition=` | `{ slug, size, condition, price, currency, checked_at, offers }` — the URL a Watch polls |
+| `GET /api/v1/drops?from&to` | `{ from, to, drops }`, soonest first |
+| `GET /api/v1/drops/:id` | one drop |
+| `GET /api/v1/me/watches` | session only: the person's watches |
+| `POST /api/v1/me/watches` | session only: `{ slug, size?, condition?, threshold, currency? }` → a price watch in OpenVibe.Watch |
+| `POST /api/v1/me/track` | session only: `{ url }` → a Watch for a pasted listing, through Watch's own rules |
+| `GET /api/v1/status` | the last read of each source, the counts, and whether eBay/Watch are configured |
+| `GET /api/openapi.json` | OpenAPI 3.1 |
+
+A signed-in write made with the session cookie must come from `open.shoes` (`Sec-Fetch-Site: same-origin`, or an
+`Origin` equal to the site): another site cannot set a watch for a visitor. A bad Bearer is refused, never
+downgraded to the session or anonymous.
+
+## Jobs (`server/jobs/*`)
+
+Each refuses to start under `SHOES_DRILL=1`; tests and `scripts/seed.js` call their `runOnce()`.
+
+| Job | Reads | Writes | Default |
+|---|---|---|---|
+| `offers` | OpenVibe.Deals' public newest offers, then each candidate's public product for its brand | a model, an offer with its all-in inputs, one snapshot per offer per day | every 30 min, 20 pages |
+| `drops` | the official release calendar and any configured RSS feeds | a drop with its source page and read time | every 6 h |
+| `ebay` | the official Browse API (only with a key pair) | an offer, the same as any other source | every 60 min, off without keys |
+
+A job records each run in `shoe_job_runs` (source, start, finish, counts, error); `/api/v1/status` and the home FAQ
+show it. The importer stops at the first page Deals answers empty, so a quiet source costs nothing.
+
+## Database
+
+PostgreSQL through `openvibe-sdk/db`, numbered migrations (`migrations/0001_initial.sql`). The tests use PGlite;
+`npm run test:pg` runs the same suite through PgBouncer.
+
+| Table | Holds |
+|---|---|
+| `shoe_models` | `shm_` + ULID, unique slug, brand, model, first seen |
+| `shoe_offers` | `sho_` + ULID, source + source id (unique), store, title, URL, price, list price, shipping, condition, size, colourway, checked time, stale, raw public item |
+| `shoe_snapshots` | `shs_` + ULID, offer, model, price, checked time, day — unique per offer per day (the first check wins) |
+| `shoe_drops` | `shd_` + ULID, unique slug, release time, price, sizes, source name/URL/key, read time |
+| `shoe_watches` | `shw_` + ULID, subject, kind `price\|link`, model, size, condition, threshold, source URL, remote Watch id, status, note |
+| `shoe_facts` | cited specs/fit/release/care: kind, label, value, source name/URL, read time |
+| `shoe_job_runs` | one row per job run: source, start, finish, ok, counts, error |
+| `account_data_events`, `telemetry_samples` | the SDK's account receipts and request telemetry |
+
+## Environment
+
+Only names in code and docs; values live in `/etc/openvibe/shoes.env` (see [.env.example](.env.example)).
+
+- **Service:** `NODE_ENV`, `PORT` (5180), `HOST`, `BASE_URL`, `TRUST_PROXY`, `COOKIE_SECURE`, `SHOES_DRILL`.
+- **Database:** `DATABASE_URL`, `DATABASE_DIRECT_URL`, `SHOES_PGLITE_DIR`, `VALKEY_URL`, `VALKEY_PREFIX`.
+- **Network:** `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_NETWORK_ISSUER`, `OV_OAUTH_CLIENT_ID`,
+ `OV_OAUTH_CLIENT_SECRET`, `OV_OAUTH_REDIRECT_URI`, `OV_SESSION_AUDIENCE`, `SHOES_AUDIENCE`.
+- **Events:** `SHOES_EVENTS_SECRET`, `SHOES_EVENTS_URL`, `SHOES_EVENTS_ENDPOINT`, `SHOES_EVENTS_INTERVAL_MS`.
+- **Sources:** `SHOES_DEALS_URL`, `SHOES_OFFERS_PAGES`, `SHOES_PRODUCT_LOOKUPS`, `SHOES_STALE_HOURS`,
+ `SHOES_UPSTREAM_TIMEOUT_MS`, `SHOES_REQUEST_GAP_MS`, `SHOES_DROPS_URL`, `SHOES_DROPS_SOURCE_NAME`, `SHOES_DROP_FEEDS`,
+ `SHOES_EBAY_CLIENT_ID`, `SHOES_EBAY_CLIENT_SECRET`, `SHOES_EBAY_MARKETPLACE`, `SHOES_EBAY_CATEGORY_ID`,
+ `SHOES_EBAY_LIMIT`.
+- **Watch:** `SHOES_WATCH_URL`, `SHOES_WATCH_INTERNAL_URL`, `SHOES_WATCH_TOKEN`, `SHOES_WATCH_AUDIENCE`,
+ `SHOES_WATCH_CAPABILITY`, `SHOES_WATCH_EVERY_SEC`.
+- **Today:** `SHOES_TODAY_URL`, `SHOES_TODAY_INTERNAL_URL`.
+- **Jobs:** `SHOES_OFFERS_INTERVAL_MS`, `SHOES_DROPS_INTERVAL_MS`, `SHOES_EBAY_INTERVAL_MS`.
+
+## Run it
+
+```bash
+npm ci --include=dev
+npm test # every unit and HTTP test on PGlite
+npm run test:pg # the same through PostgreSQL + PgBouncer
+npm run seed # one first run of every job against the live sources (needs the network)
+npm start # http://127.0.0.1:5180
+```
+
+Without `DATABASE_URL`, development uses an embedded PGlite database in `data/pglite` (one process only). A
+production start needs `DATABASE_URL` and `DATABASE_DIRECT_URL`; the owner role migrates on boot.
+
+## Deploy
+
+Copy `deploy/systemd/openvibe-shoes.service` to `/etc/systemd/system/` and
+`deploy/nginx/open.shoes.conf` to `/etc/nginx/sites-available/`; the unit reads `/etc/openvibe/shoes.env`, works in
+`/opt/openvibe.shoes`, runs as `ubuntu:ubuntu` on `127.0.0.1:5180`, and the vhost proxies `/api/`, `/auth/` and the
+forms with their own limits. The `.gitignore` keeps `node_modules`, `data`, `.env` and keys out of the repo.
+
+## First run (seed)
+
+`npm run seed` reads the live sources once and prints what it wrote. A source that does not answer is reported as
+a plain note and does not fail the other jobs. The first local run (2026-10-31, 60 Deals pages) wrote:
+
+| Source | Result |
+|---|---|
+| OpenVibe.Deals | 1,500 offers read, 182 footwear candidates, 165 product lookups, **178 offers** |
+| Nike SNKRS launch calendar | **16 official releases** |
+| eBay | off (no developer key pair) |
+| **Catalogue** | **169 models · 178 offers · 178 daily snapshots · 16 drops · 0 facts** |
+
+The same counts are in [STATUS.json](STATUS.json) under `seed`. The catalogue opens with real models and real
+releases; the price history is one day deep and grows with every daily check, and the "biggest price drops today"
+section says so plainly when no store has cut a price yet.
+
+## What v1 does not do
+
+- No raffle, draw, reservation or checkout is automated, ever; the drops calendar links the official page and
+ adds a reminder on Today.
+- Specs/fit/release/care are shown only from cited sources; with none, the page says "not known yet".
+- eBay is off until the host sets a developer key pair; Watch is off until the host sets a service token (the
+ watch is still saved on Shoes and marked "not connected").
+- No affiliate links, no sponsored order, no Marketplace scraping.
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+# Security policy
+
+OpenVibe is run in the open, and we want to hear about security problems before anyone else does.
+
+## Reporting a vulnerability
+
+Email **[email protected]** with "security" in the subject. Please include what you found, where (URL,
+repository, file), how to reproduce it, and what an attacker could do with it. Do not open a public GitHub issue for
+a vulnerability, and do not test against other people's accounts, data or streams.
+
+We reply within 7 days, keep you updated while we fix it, and credit you when the fix ships if you would like.
+
+## Scope
+
+Every OpenVibe service and site (open.shoes, openvibe.live, openvibe.network, openvibe.today, openvibe.tools,
+openvibe.media, openvibe.community, openvibe.games and the other openvibe.* domains) and every repository under
+github.com/OpenVibers. Machine-readable contact details are at `/.well-known/security.txt` on each site.
+
+## What OpenVibe.Shoes holds
+
+- Public models, offers, daily snapshots, drops and cited facts: the stores' public listings, prices, all-in
+ inputs, sizes, conditions, colourways, source pages, check times and read times. They are the network's public
+ record, not a person's row, so they are not exported or erased with an account.
+- A person's watches: the subject id, the kind (a price watch or a pasted link), the model/size/condition, the
+ threshold and a public URL. These are exported and erased with an account.
+- Network session tokens only in httpOnly cookies (`shoes_at`, `shoes_rt`); no token is stored in the database,
+ rendered into a page or logged. The Watch service token and eBay key pair are environment values and never
+ stored or logged.
+- A pasted URL is checked by `openvibe-shared/egress` before it is handed to Watch; Shoes never fetches the URL
+ itself, and Facebook Marketplace is never scraped.
+
+The `/internal/events` account route is loopback only and refuses a proxied request; nginx answers 404 for
+`/internal` and `/metrics`.
+
+## Supported versions
+
+Only the current `main` branch, which is what runs in production, receives fixes.
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+{
+ "repository": "OpenVibers/OpenVibe.Shoes",
+ "version": "0.1.0",
+ "stage": "product",
+ "code": true,
+ "plan": "OpenVibe.Shoes: every shoe, every market, the right price — one page per model with every store's all-in price, Shoes' own daily price snapshots, the size-price curve, cited specs, a price watch at a size and paste-a-link tracking through OpenVibe.Watch, and official drops with a reminder on Today",
+ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
+ "port": "5180",
+ "domain": "open.shoes",
+ "contracts": "openvibe-contracts v0.234.0",
+ "sdk": "openvibe-sdk v0.70.0",
+ "shared": "openvibe-shared v4.31.0",
+ "packages": [
+ "openvibe-contracts v0.234.0",
+ "openvibe-sdk v0.70.0",
+ "openvibe-shared v4.31.0"
+ ],
+ "summary": "OpenVibe.Shoes is the network's shoe resource. It reads OpenVibe.Deals' public offers (and the official eBay Browse API when a key pair is set), keeps the footwear in its own catalogue, and shows one page per model with every store's all-in price, a price history from its own one-snapshot-per-offer-per-day record, the size-price curve where sizes are known, and specs/fit/release/care only from cited open sources. A signed-in person can ask Watch to tell them when the model drops below a price at their size, or paste any listing link and have Watch apply its own fetch rules. Official release dates come from the brands' and stores' own public release pages; a reminder goes on Today. Shoes never enters a raffle, never scrapes Marketplace and carries no affiliate links.",
+ "works": [
+ "Models and offers with a unique slug, all-in price, list price, condition, size, colourway, source check time and stale flag",
+ "One snapshot per offer per day (the first check wins), the price history and the size-price curve from those snapshots",
+ "The offers job over OpenVibe.Deals' public API, with the footwear classifier and product brand lookup",
+ "The drops job over a brand's or store's own release page, plus optional RSS feeds; every release cited and dated",
+ "A price watch at a size and a pasted listing link through OpenVibe.Watch's API (service token + X-OV-Subject)",
+ "A drop reminder on OpenVibe.Today with the person's own token; no automated raffle entries",
+ "The public API: search with facets, one model with offers/history/curve/facts, the price a Watch polls, drops, the person's watches, source status, OpenAPI 3.1",
+ "The home via sc.room: hero, proof, biggest price drops today, dropping this week, popular models, three scenes, explore, developers, FAQ, network",
+ "Server-rendered pages, discovery artifacts, cache headers, account export/deletion and deploy files",
+ "Tests on PGlite and PostgreSQL: pure rules, the catalogue, the job parsers, every HTTP route"
+ ],
+ "notYet": [
+ "eBay is off until the host sets SHOES_EBAY_CLIENT_ID and SHOES_EBAY_CLIENT_SECRET",
+ "OpenVibe.Watch is off until the host sets SHOES_WATCH_TOKEN; a watch is then saved on Shoes and marked not_connected",
+ "Specs, fit notes and care notes are empty until a cited open source is added to the shoe_facts table; the pages say not known yet",
+ "The size-price curve is empty until a source states the size; no size is invented",
+ "No closet, image search or merchant inventory feed (plan v2/v3)",
+ "The drops parser knows the Nike SNKRS launch payload and plain RSS; other calendars need their own parser"
+ ],
+ "capabilitiesRegistered": false,
+ "serviceManifestRegistered": false,
+ "deployed": false,
+ "placeholderInSites": false,
+ "seed": {
+ "run_at": "2026-10-31T22:34:27Z",
+ "sources": {
+ "offers": {
+ "ok": true,
+ "pages": 60,
+ "seen": 1500,
+ "candidates": 182,
+ "product_lookups": 165,
+ "offers": 178,
+ "skipped": 4
+ },
+ "drops": {
+ "ok": true,
+ "sources": 1,
+ "releases": 16,
+ "drops": 16,
+ "skipped": 0
+ },
+ "ebay": {
+ "ok": true,
+ "configured": false,
+ "offers": 0,
+ "note": "off: no developer key pair"
+ }
+ },
+ "counts": {
+ "models": 169,
+ "offers": 178,
+ "snapshots": 178,
+ "drops": 16,
+ "watches": 0,
+ "facts": 0
+ }
+ },
+ "features": [
+ "models and offers",
+ "all-in price with stale flag",
+ "daily price snapshots and history",
+ "size-price curve",
+ "cited facts or not known yet",
+ "offers job",
+ "drops job",
+ "eBay job (off without keys)",
+ "watch at a size",
+ "paste-a-link tracking",
+ "drop reminders on Today",
+ "public API and OpenAPI",
+ "account export and deletion",
+ "Network sign-in",
+ "server-rendered pages",
+ "discovery",
+ "deploy files"
+ ],
+ "deployment": "not deployed: the unit and nginx vhost are deploy/systemd/openvibe-shoes.service and deploy/nginx/open.shoes.conf",
+ "updated": "2026-10-31"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+## ═══════════════════════════════════════════════════════════════
+## open.shoes — Nginx configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/open.shoes.conf (ov-vhost-install) — keep them in sync and reload
+## nginx after changing it. The Node app (port 5180, systemd unit openvibe-shoes) renders every page and serves
+## the API; nginx terminates TLS, redirects www → apex, rate-limits sign-in, the API and the forms, and compresses
+## text.
+##
+## No proxy_cache: the app decides cacheability per response (public for the model pages and the catalogue; private,
+## no-store for anything drawn for a signed-in person). Never log request bodies here.
+## ═══════════════════════════════════════════════════════════════
+
+limit_req_zone $binary_remote_addr zone=shoes_auth:10m rate=10r/m;
+limit_req_zone $binary_remote_addr zone=shoes_api:10m rate=5r/s;
+limit_req_zone $binary_remote_addr zone=shoes_forms:10m rate=1r/s;
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name open.shoes www.open.shoes;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://open.shoes$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.open.shoes;
+
+ ssl_certificate /etc/letsencrypt/live/open.shoes/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/open.shoes/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ return 301 https://open.shoes$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name open.shoes;
+
+ ssl_certificate /etc/letsencrypt/live/open.shoes/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/open.shoes/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/open.shoes.access.log;
+ error_log /var/log/nginx/open.shoes.error.log;
+
+ # The API's JSON body cap is 64 KB; leave room for the headers.
+ client_max_body_size 128k;
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+ gzip on;
+ gzip_proxied any;
+ gzip_vary on;
+ gzip_comp_level 5;
+ gzip_min_length 1024;
+ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;
+
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # ── Network SSO session endpoints ───────────────────────
+ # The navbar's session probe runs on every page view: it gets the API's budget, not sign-in's.
+ location = /auth/me {
+ limit_req zone=shoes_api burst=30 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5180;
+ }
+
+ location /auth/ {
+ limit_req zone=shoes_auth burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5180;
+ }
+
+ # ── API ─────────────────────────────────────────────────
+ location /api/ {
+ limit_req zone=shoes_api burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5180;
+ }
+
+ # ── Forms and other writes: the price watch, the pasted link, the drop reminder ──
+ location ~ ^/(shoe/[a-z0-9-]+/watch|track|drops/[^/]+/remind)$ {
+ limit_req zone=shoes_forms burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5180;
+ }
+
+ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5180/metrics) ──
+ location ~* ^/metrics(/|$) { return 404; }
+
+ # ── OpenVibe.Events' account delivery route: loopback only (POST /internal/events) ──
+ # Events posts to http://127.0.0.1:5180/internal/events directly. Never proxied from outside (ADR-033).
+ location ~* ^/internal(/|$) { return 404; }
+
+ # ── Pages, docs, robots.txt, sitemap, static assets ─────
+ location / {
+ proxy_pass http://127.0.0.1:5180;
+ }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+[Unit]
+Description=OpenVibe.Shoes — every shoe, every market, the right price.
+# Network (sign-in, JWKS, account events) should be up first, but Shoes degrades honestly without it: the public
+# pages and API serve, and sign-in and the guarded routes wait for the signing key (see /api/ready).
+After=network.target postgresql.service pgbouncer.service valkey-server.service
+Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/openvibe.shoes
+EnvironmentFile=/etc/openvibe/shoes.env
+Environment=NODE_ENV=production
+Environment=PORT=5180
+Environment=HOST=127.0.0.1
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.shoes/server/index.js
+Restart=always
+RestartSec=5
+KillSignal=SIGTERM
+# In-flight requests get the 10 s drain.
+TimeoutStopSec=15
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+# OpenVibe.Shoes' own database is PostgreSQL on the host's data role; the code tree stays read-only.
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,195 @@
@@ -0,0 +1,195 @@
+-- phase: expand
+-- OpenVibe.Shoes: the shoe catalogue (open.shoes). Every offer comes from an official source with the time it was
+-- checked; every price history point is one snapshot per offer per day; every drop cites the release page it came
+-- from; every spec or care note is cited to the open source that said it, or the page says "not known yet".
+-- Nothing here is a secret: a watch holds a person's subject id, a threshold and the public URL they pasted.
+
+-- One model: a brand and a model name ("Nike" + "Air Max 90"). The slug is the page: /shoe/<brand>-<model>.
+CREATE TABLE shoe_models (
+ id text COLLATE "C" PRIMARY KEY, -- shm_ + 26 Crockford base32 characters (ULID)
+ slug text COLLATE "C" NOT NULL UNIQUE, -- /shoe/<brand>-<model>
+ brand text COLLATE "C" NOT NULL,
+ model text COLLATE "C" NOT NULL,
+ name text COLLATE "C" NOT NULL, -- the model's name as its source wrote it
+ first_seen_at timestamptz NOT NULL,
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT shoe_models_id CHECK (id ~ '^shm_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT shoe_models_slug CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$' AND length(slug) <= 80),
+ CONSTRAINT shoe_models_brand CHECK (length(brand) BETWEEN 1 AND 80),
+ CONSTRAINT shoe_models_model CHECK (length(model) BETWEEN 1 AND 120),
+ CONSTRAINT shoe_models_unique UNIQUE (brand, model)
+);
+CREATE INDEX shoe_models_brand ON shoe_models (brand);
+
+-- One offer: a store's listing of one model, with the price, the shipping when the store states it, the list price
+-- when the source gives one, the condition, the size when the source names one, the time the source was checked and
+-- a stale flag. raw holds the source's own item (public, no credentials).
+CREATE TABLE shoe_offers (
+ id text COLLATE "C" PRIMARY KEY, -- sho_ + 26 Crockford base32 characters (ULID)
+ model_id text COLLATE "C" NOT NULL REFERENCES shoe_models (id) ON DELETE CASCADE,
+ source text COLLATE "C" NOT NULL, -- the source that published it (deals | ebay)
+ source_id text COLLATE "C" NOT NULL, -- that source's own id
+ store_domain text COLLATE "C" NOT NULL,
+ store_name text,
+ title text COLLATE "C" NOT NULL,
+ url text NOT NULL, -- the store's own page for the listing
+ price_amount numeric(10,2), -- the price as the source stated it
+ list_price_amount numeric(10,2), -- the source's own "was" price, when it states one
+ currency text COLLATE "C" NOT NULL DEFAULT 'USD',
+ shipping_amount numeric(10,2), -- null when the store states no shipping
+ condition text COLLATE "C", -- new | used | deadstock | null (not stated)
+ size text COLLATE "C", -- the size, when the source names one
+ colourway text COLLATE "C",
+ checked_at timestamptz NOT NULL, -- when the source was checked
+ stale boolean NOT NULL DEFAULT false,
+ active boolean NOT NULL DEFAULT true,
+ raw jsonb NOT NULL DEFAULT '{}'::jsonb, -- the source's own public item
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT shoe_offers_id CHECK (id ~ '^sho_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT shoe_offers_unique UNIQUE (source, source_id),
+ CONSTRAINT shoe_offers_price CHECK (price_amount IS NULL OR price_amount >= 0),
+ CONSTRAINT shoe_offers_list_price CHECK (list_price_amount IS NULL OR list_price_amount >= 0),
+ CONSTRAINT shoe_offers_condition CHECK (condition IS NULL OR condition IN ('new', 'used', 'deadstock')),
+ CONSTRAINT shoe_offers_url CHECK (url ~ '^https?://')
+);
+CREATE INDEX shoe_offers_model ON shoe_offers (model_id, active);
+CREATE INDEX shoe_offers_store ON shoe_offers (store_domain);
+CREATE INDEX shoe_offers_checked ON shoe_offers (checked_at DESC);
+CREATE INDEX shoe_offers_size ON shoe_offers (model_id, size) WHERE size IS NOT NULL;
+
+-- The price history: one snapshot per offer per day (the first check of that day wins).
+CREATE TABLE shoe_snapshots (
+ id text COLLATE "C" PRIMARY KEY, -- shs_ + 26 Crockford base32 characters (ULID)
+ offer_id text COLLATE "C" NOT NULL REFERENCES shoe_offers (id) ON DELETE CASCADE,
+ model_id text COLLATE "C" NOT NULL REFERENCES shoe_models (id) ON DELETE CASCADE,
+ price_amount numeric(10,2),
+ shipping_amount numeric(10,2), -- the shipping the offer stated that day (null: not stated)
+ checked_at timestamptz NOT NULL,
+ day date NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT shoe_snapshots_id CHECK (id ~ '^shs_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT shoe_snapshots_unique UNIQUE (offer_id, day)
+);
+CREATE INDEX shoe_snapshots_model_day ON shoe_snapshots (model_id, day DESC);
+
+-- A drop: an official release date, with the page it was read from and the time it was read.
+CREATE TABLE shoe_drops (
+ id text COLLATE "C" PRIMARY KEY, -- shd_ + 26 Crockford base32 characters (ULID)
+ slug text COLLATE "C" NOT NULL UNIQUE,
+ brand text COLLATE "C" NOT NULL,
+ model text COLLATE "C" NOT NULL,
+ colourway text COLLATE "C",
+ release_at timestamptz NOT NULL,
+ price_amount numeric(10,2),
+ currency text COLLATE "C" NOT NULL DEFAULT 'USD',
+ sizes jsonb NOT NULL DEFAULT '[]'::jsonb, -- the size grid the source published
+ source_name text COLLATE "C" NOT NULL, -- who published the release date
+ source_url text NOT NULL, -- the release page itself
+ source_key text COLLATE "C" NOT NULL, -- that source's own key for the drop
+ fetched_at timestamptz NOT NULL, -- when we read it
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT shoe_drops_id CHECK (id ~ '^shd_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT shoe_drops_slug CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$' AND length(slug) <= 80),
+ CONSTRAINT shoe_drops_url CHECK (source_url ~ '^https?://'),
+ CONSTRAINT shoe_drops_unique UNIQUE (source_name, source_key)
+);
+CREATE INDEX shoe_drops_release ON shoe_drops (release_at);
+
+-- A person's watches: a price watch (this model, this size, below this price) or a pasted listing link.
+-- The watch itself lives in OpenVibe.Watch; this row is what Shoes shows and links.
+CREATE TABLE shoe_watches (
+ id text COLLATE "C" PRIMARY KEY, -- shw_ + 26 Crockford base32 characters (ULID)
+ subject text COLLATE "C" NOT NULL, -- usr_ + 26 Crockford base32 characters
+ kind text COLLATE "C" NOT NULL, -- price | link
+ model_id text COLLATE "C" REFERENCES shoe_models (id) ON DELETE SET NULL,
+ slug text COLLATE "C",
+ size text COLLATE "C",
+ condition text COLLATE "C",
+ threshold_amount numeric(10,2),
+ currency text COLLATE "C" NOT NULL DEFAULT 'USD',
+ source_url text,
+ remote_id text, -- the watch id in OpenVibe.Watch
+ status text COLLATE "C" NOT NULL DEFAULT 'active', -- active | not_connected | failed
+ note text,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT shoe_watches_id CHECK (id ~ '^shw_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT shoe_watches_kind CHECK (kind IN ('price', 'link')),
+ CONSTRAINT shoe_watches_status CHECK (status IN ('active', 'not_connected', 'failed')),
+ CONSTRAINT shoe_watches_threshold CHECK (threshold_amount IS NULL OR threshold_amount > 0),
+ CONSTRAINT shoe_watches_source_url CHECK (source_url IS NULL OR source_url ~ '^https?://')
+);
+CREATE INDEX shoe_watches_subject ON shoe_watches (subject, created_at DESC);
+
+-- A cited open fact: a spec, a fit note, a release fact or a care note. Only ever inserted with its source;
+-- a model with no rows says "not known yet" instead of guessing.
+CREATE TABLE shoe_facts (
+ id text COLLATE "C" PRIMARY KEY, -- shf_ + 26 Crockford base32 characters (ULID)
+ model_id text COLLATE "C" NOT NULL REFERENCES shoe_models (id) ON DELETE CASCADE,
+ kind text COLLATE "C" NOT NULL, -- specs | fit | release | care
+ label text COLLATE "C" NOT NULL,
+ value text NOT NULL,
+ source_name text COLLATE "C" NOT NULL,
+ source_url text NOT NULL,
+ fetched_at timestamptz NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT shoe_facts_id CHECK (id ~ '^shf_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT shoe_facts_kind CHECK (kind IN ('specs', 'fit', 'release', 'care')),
+ CONSTRAINT shoe_facts_url CHECK (source_url ~ '^https?://')
+);
+CREATE INDEX shoe_facts_model ON shoe_facts (model_id, kind);
+
+-- What each job did, so a page or a person can say when a source was last read and whether it answered.
+CREATE TABLE shoe_job_runs (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ job text COLLATE "C" NOT NULL, -- offers | drops | ebay
+ source_name text COLLATE "C" NOT NULL,
+ source_url text,
+ started_at timestamptz NOT NULL,
+ finished_at timestamptz,
+ ok boolean,
+ counts jsonb NOT NULL DEFAULT '{}'::jsonb,
+ error text
+);
+CREATE INDEX shoe_job_runs_job ON shoe_job_runs (job, started_at DESC);
+
+-- openvibe-sdk/account-data keeps its own idempotency here (ADR-033): a redelivered export or deletion changes
+-- nothing. It holds an export_id or deletion_id, the subject and the counts sent to Network; it is not a secret.
+CREATE TABLE account_data_events (
+ id text PRIMARY KEY,
+ kind text NOT NULL,
+ subject text NOT NULL,
+ outcome jsonb,
+ applied_at timestamptz NOT NULL DEFAULT now(),
+ sent_at timestamptz
+);
+
+-- Request telemetry (openvibe-sdk/telemetry): one row per aggregated sample, pruned after 14 days.
+CREATE TABLE telemetry_samples (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ at timestamptz NOT NULL,
+ service text NOT NULL,
+ project text,
+ subject text,
+ resource text,
+ provider text,
+ node text,
+ cell text,
+ region text,
+ operation text NOT NULL,
+ latency_ms double precision,
+ queue_delay_ms double precision,
+ ttfb_ms double precision,
+ throughput_per_second double precision,
+ bytes bigint,
+ status text,
+ cache_status text,
+ cost_estimate double precision,
+ route_epoch bigint,
+ trace_id text,
+ extra jsonb NOT NULL DEFAULT '{}'::jsonb
+);
+CREATE INDEX telemetry_samples_at_idx ON telemetry_samples (at);
+CREATE INDEX telemetry_samples_service_idx ON telemetry_samples (service, operation, at);
new file mode 100644
new file mode 100644
@@ -0,0 +1,1407 @@
@@ -0,0 +1,1407 @@
+{
+ "name": "openvibe-shoes",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-shoes",
+ "version": "0.1.0",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "helmet": "^8.1.0",
+ "iovalkey": "^0.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.265.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
+ "pg": "^8.23.1"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.13",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",
+ "integrity": "sha512-4rZuxK1k07w6sSpJzjMri0B9+HuXuC7V9pxVB51pR0T9KPqXg6TD0mdaEwBA9rkcL4jdht4tGDf/wYzAiGhT7g==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.9",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz",
+ "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.16.0",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "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/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "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-parser": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
+ "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/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "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/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "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/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "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": "4.22.3",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
+ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.13",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.16.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "7.5.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
+ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.9",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
+ "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "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": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "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/helmet": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
+ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/EvanHahn"
+ }
+ },
+ "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.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "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/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/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/iovalkey/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/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/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/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/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "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": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "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.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "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.2",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
+ "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/openvibe-contracts": {
+ "version": "0.265.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.265.0",
+ "integrity": "sha512-0KnKj1Rig6acZcv4U2uwaM2msarGUY36pNqFbqmKdLeOPPozf7haEDTam27A/WzSU9ii2yEZ6SnuusPPQoGZaA==",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1"
+ },
+ "bin": {
+ "openvibe-contracts-check": "bin/check-service.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/openvibe-sdk": {
+ "version": "0.70.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
+ "integrity": "sha512-iMNxlnRm0fmyj60nHiC/lKAaG3JAqiJgu42lShnkby4yF4dQJRG3G0trrqOoULZKBalrYHb/gjauRwNQYN/tog==",
+ "license": "MIT",
+ "bin": {
+ "openvibe-download": "bin/openvibe-download.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0"
+ },
+ "peerDependenciesMeta": {
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "iovalkey": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/openvibe-shared": {
+ "version": "4.31.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
+ "integrity": "sha512-Zk1t1RGsiszXJEyW3x/cVCYC6U1qyLXID43l/Ehv2Py5gGglz5CfgYlPcDGI7n3QACsCWoOIGZuMBvlyfxyXEA==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ },
+ "bin": {
+ "openvibe-build-assets": "scripts/build-assets.js"
+ }
+ },
+ "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": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/pg": {
+ "version": "8.23.1",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
+ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.1",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.1",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.1"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
+ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
+ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
+ "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.16.1",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
+ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
+ "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.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.16.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",
+ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",
+ "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.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "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/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "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": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "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/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
+ "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": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "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/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "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/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"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+{
+ "name": "openvibe-shoes",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe.Shoes — every shoe, every market, the right price.",
+ "main": "server/index.js",
+ "scripts": {
+ "start": "node server/index.js",
+ "dev": "NODE_ENV=development node server/index.js",
+ "seed": "node scripts/seed.js",
+ "test": "node test/run.js",
+ "test:pg": "SHOES_TEST_STORE=pg node test/run.js"
+ },
+ "keywords": [
+ "openvibe"
+ ],
+ "author": "OpenVibers",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "helmet": "^8.1.0",
+ "iovalkey": "^0.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.265.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
+ "pg": "^8.23.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+/* The page's own type and the skip link (the room template carried neither). */
+.sh { font-family: var(--ovk-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif); }
+.skip { position: absolute; left: -9999px; }
+.skip:focus { left: 12px; top: 12px; z-index: 99; padding: 8px 12px; border-radius: 10px; background: var(--bg-card, #0f1729); color: inherit; }
+
+/* OpenVibe.Shoes — the small set of page pieces the kit does not draw (tables, forms, the price chart).
+ The room's look and every card, rail and scene come from kit.css, room.css and showcase.css. */
+.sh-wrap { width: min(1180px, 100% - 32px); margin: 0 auto; }
+.sh-page { padding: 28px 0 64px; }
+.sh-page-h { max-width: 780px; margin: 0 0 18px; }
+.sh-page-h h1 { margin: 0 0 8px; font-size: clamp(26px, 4vw, 40px); letter-spacing: -.02em; }
+.sh-kicker { margin: 0 0 6px; font-size: 12px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; color: var(--room-accent, var(--accent)); }
+.sh-lede { margin: 0; color: var(--text-secondary, #96a7c2); line-height: 1.6; font-size: 16.5px; }
+.sh-muted { color: var(--text-muted, #7386a3); }
+.sh-fine { margin: 10px 0 0; color: var(--text-muted, #7386a3); font-size: 13px; line-height: 1.55; }
+.sh-good { color: var(--ok, #34d399); font-weight: 700; }
+.sh-stale { color: var(--warn, #fbbf24); font-weight: 700; }
+.sh-fresh { color: var(--ok, #34d399); font-weight: 700; }
+.sh-sec { margin: 34px 0 0; }
+.sh-sec h2 { margin: 0 0 8px; font-size: clamp(20px, 2.4vw, 26px); }
+.sh-sec h3 { margin: 0 0 4px; font-size: 15px; }
+.sh-btn { display: inline-flex; align-items: center; gap: 7px; min-height: 40px; padding: 8px 14px; border: 1px solid var(--room-accent, var(--accent)); border-radius: 10px; background: var(--room-accent, var(--accent)); color: #fff; font: inherit; font-weight: 700; text-decoration: none; cursor: pointer; }
+.sh-btn svg { width: 18px; height: 18px; }
+.sh-btn-quiet { background: transparent; color: var(--room-strong, var(--text-primary)); }
+.sh-btn:hover { filter: brightness(1.06); }
+.sh-search { display: grid; grid-template-columns: minmax(200px, 2fr) repeat(5, minmax(110px, 1fr)) auto; gap: 10px; align-items: end; padding: 14px; margin: 0 0 20px; border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 14px; background: var(--bg-secondary, #101828); }
+.sh-search label { display: grid; gap: 5px; font-size: 12.5px; font-weight: 700; color: var(--text-secondary, #96a7c2); }
+.sh-search input, .sh-search select { width: 100%; min-height: 40px; padding: 8px 10px; border: 1px solid var(--border, rgba(255,255,255,.14)); border-radius: 9px; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: inherit; }
+.sh-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 14px; margin: 0; padding: 0; list-style: none; }
+.sh-grid > li { min-width: 0; }
+.sh-empty { padding: 28px; margin: 20px 0; border: 1px dashed var(--border, rgba(255,255,255,.16)); border-radius: 16px; }
+.sh-notice { padding: 12px 14px; margin: 14px 0; border: 1px solid var(--room-accent, var(--accent)); border-radius: 12px; background: color-mix(in srgb, var(--room-accent, var(--accent)) 12%, transparent); }
+.sh-table-wrap { overflow-x: auto; margin: 10px 0; }
+.sh-table { width: 100%; border-collapse: collapse; font-size: 14px; }
+.sh-table caption { text-align: left; padding: 0 0 8px; color: var(--text-muted, #7386a3); font-size: 13px; }
+.sh-table th, .sh-table td { padding: 9px 10px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); text-align: left; vertical-align: top; }
+.sh-table th { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.sh-table small { color: var(--text-muted, #7386a3); }
+.sh-watch { margin: 20px 0; padding: 18px; border: 1px solid var(--room-accent, var(--accent)); border-radius: 16px; background: var(--bg-secondary, #101828); }
+.sh-watch h2 { margin: 0 0 6px; font-size: 20px; }
+.sh-form-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: end; }
+.sh-form-row label { display: grid; gap: 5px; font-size: 12.5px; font-weight: 700; color: var(--text-secondary, #96a7c2); }
+.sh-form-row input, .sh-form-row select { min-height: 40px; padding: 8px 10px; border: 1px solid var(--border, rgba(255,255,255,.14)); border-radius: 9px; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: inherit; }
+.sh-shoe-h { margin: 14px 0 18px; }
+.sh-shoe-h h1 { margin: 0 0 8px; font-size: clamp(28px, 5vw, 46px); letter-spacing: -.02em; }
+.sh-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; padding: 0; list-style: none; }
+.sh-chips li { padding: 5px 10px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.14)); font-size: 13px; }
+.sh-chart { margin: 10px 0; padding: 12px; border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 14px; background: var(--bg-secondary, #101828); }
+.sh-chart svg { display: block; width: 100%; height: auto; }
+.sh-chart-axis { stroke: var(--border, rgba(255,255,255,.16)); stroke-width: 1; }
+.sh-chart-line { stroke: var(--room-accent, var(--accent)); stroke-width: 2.5; stroke-linejoin: round; }
+.sh-chart circle { fill: var(--room-accent, var(--accent)); }
+.sh-chart figcaption { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: baseline; margin-top: 6px; font-size: 13px; color: var(--text-muted, #7386a3); }
+.sh-chart figcaption b { font-size: 18px; color: var(--text-primary, #e6edf7); }
+.sh-curve { display: flex; gap: 8px; align-items: end; overflow-x: auto; padding: 10px 2px; }
+.sh-curve-point { display: grid; justify-items: center; gap: 3px; min-width: 54px; font-size: 12px; }
+.sh-curve-size { font-weight: 800; }
+.sh-curve-bar { display: block; width: 26px; height: 110px; border-radius: 6px 6px 0 0; background: linear-gradient(to top, color-mix(in srgb, var(--room-accent, var(--accent)) 30%, transparent), var(--room-accent, var(--accent))); clip-path: inset(calc(100% - var(--h)) 0 0 0); }
+.sh-curve-point small { color: var(--text-muted, #7386a3); }
+.sh-facts { display: grid; gap: 14px; margin: 34px 0 0; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); }
+.sh-facts > h2, .sh-facts > p { grid-column: 1 / -1; }
+.sh-fact { padding: 14px; border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 14px; background: var(--bg-secondary, #101828); }
+.sh-fact dl { margin: 0; } .sh-fact dt { font-weight: 700; margin-top: 8px; } .sh-fact dd { margin: 2px 0 0; color: var(--text-secondary, #96a7c2); }
+.sh-drops { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
+.sh-drop { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; padding: 14px; border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 14px; background: var(--bg-secondary, #101828); }
+.sh-drop > div { display: grid; gap: 3px; min-width: 240px; }
+.sh-drop-meta, .sh-drop-src { color: var(--text-muted, #7386a3); font-size: 13px; }
+.sh-colour { color: var(--text-secondary, #96a7c2); }
+.sh-drop-day h2 { margin: 26px 0 8px; font-size: 20px; }
+.sh-scene-note, .sh-scene-drop, .sh-scene-track { padding: 16px; border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 14px; background: var(--bg-secondary, #101828); }
+.sh-scene-drop { display: grid; gap: 5px; }
+.sh-scene-drop b { font-size: 20px; }
+.sh-scene-tag { justify-self: start; padding: 3px 9px; border-radius: 999px; background: color-mix(in srgb, var(--room-accent, var(--accent)) 18%, transparent); color: var(--room-strong, var(--text-primary)); font-size: 12px; font-weight: 800; }
+.sh-scene-track { display: grid; gap: 8px; }
+.sh-scene-track > div { display: flex; flex-wrap: wrap; gap: 8px; }
+.sh-scene-track input, .sh-track-form input { flex: 1 1 260px; min-height: 40px; padding: 8px 10px; border: 1px solid var(--border, rgba(255,255,255,.14)); border-radius: 9px; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: inherit; }
+.sh-track-form { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; padding: 16px; border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 14px; background: var(--bg-secondary, #101828); }
+.sh-track-form label { flex-basis: 100%; font-weight: 700; }
+.sh-list { padding-left: 20px; line-height: 1.65; color: var(--text-secondary, #96a7c2); }
+.sh-code { overflow-x: auto; padding: 12px 14px; border-radius: 12px; background: var(--bg-secondary, #101828); border: 1px solid var(--border, rgba(255,255,255,.1)); }
+.sh-page-nav { display: flex; gap: 14px; align-items: center; justify-content: center; margin: 24px 0; }
+.sh-page-nav .is-off { opacity: .4; pointer-events: none; }
+.crumbs { font-size: 13px; color: var(--text-muted, #7386a3); margin: 10px 0 0; }
+.crumbs a { color: inherit; }
+@media (max-width: 900px) { .sh-search { grid-template-columns: 1fr 1fr; } }
+@media (max-width: 640px) { .sh-search { grid-template-columns: 1fr; } .sh-wrap { width: min(100% - 24px, 1180px); } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * One first run of every source, locally, and a printed report of what was written.
+ *
+ * node scripts/seed.js # offers + drops + ebay (if configured)
+ * SHOES_OFFERS_PAGES=80 node scripts/seed.js
+ *
+ * It reads the live public sources (OpenVibe.Deals, the official release page, eBay only with keys). It writes
+ * the same database the service uses: PostgreSQL when DATABASE_URL is set, else the development PGlite directory.
+ * Nothing is invented: a source that does not answer is reported and the other jobs still run.
+ */
+const configLib = require('../server/config');
+const { openStore } = require('../server/db');
+const { createCatalogue } = require('../server/catalogue');
+const { createUpstreams } = require('../server/upstreams');
+const { createOffersJob } = require('../server/jobs/offers');
+const { createDropsJob } = require('../server/jobs/drops');
+const { createEbayJob } = require('../server/jobs/ebay');
+
+async function main() {
+ const config = configLib.load();
+ const log = console;
+ const s = await openStore(config, { log });
+ const catalogue = createCatalogue(s, { staleHours: config.sources.staleHours });
+ const upstreams = createUpstreams({ config, log });
+ const offers = createOffersJob({ config, catalogue, upstreams, log });
+ const drops = createDropsJob({ config, catalogue, upstreams, log });
+ const ebay = createEbayJob({ config, catalogue, upstreams, log });
+ const report = { started_at: new Date().toISOString(), sources: {}, counts: {} };
+
+ try {
+ const steps = [
+ ['offers', () => offers.runOnce({ pages: config.sources.offersPages, maxProductLookups: config.sources.productLookups })],
+ ['drops', () => drops.runOnce()],
+ ['ebay', () => ebay.runOnce()],
+ ];
+ for (const [name, run] of steps) {
+ const at = Date.now();
+ try {
+ const out = await run();
+ report.sources[name] = { ok: true, ms: Date.now() - at, ...out };
+ console.log(`[seed] ${name}: ok in ${Date.now() - at} ms`, out);
+ } catch (err) {
+ report.sources[name] = { ok: false, ms: Date.now() - at, error: err && err.message ? err.message : String(err) };
+ console.warn(`[seed] ${name}: failed — ${err && err.message ? err.message : err}`);
+ }
+ }
+ report.counts = await catalogue.counts();
+ report.jobs = await catalogue.lastJobRuns();
+ report.finished_at = new Date().toISOString();
+ console.log('\n[seed] counts:', report.counts);
+ console.log('[seed] jobs:', report.jobs.map((j) => `${j.job}:${j.ok ? 'ok' : 'failed'}`).join(' '));
+ console.log(`[seed] ${JSON.stringify(report)}`);
+ } finally {
+ await s.close();
+ }
+ return report;
+}
+
+if (require.main === module) {
+ main().then((report) => {
+ const any = Object.values(report.sources).some((x) => x.ok);
+ process.exit(any ? 0 : 1);
+ }, (err) => { console.error('[seed] fatal:', err); process.exit(1); });
+}
+
+module.exports = { main };
new file mode 100644
new file mode 100644
@@ -0,0 +1,180 @@
@@ -0,0 +1,180 @@
+'use strict';
+
+/**
+ * OpenVibe.Shoes — Express app factory; server/index.js listens, tests build their own instance with a temp
+ * database and a mock Network.
+ *
+ * /, /shoes, /shoe/:slug, /drops, /track, /watches, /developers the pages (http/pages.js)
+ * /api/v1/* the API (http/api.js)
+ * /api/openapi.json the OpenAPI document
+ * /auth/* Network SSO with PKCE (auth/sso.js)
+ * /api/health, /api/ready, /release.json, /metrics (loopback only)
+ * /internal/events account export/deletion (loopback only)
+ *
+ * The offers, drops and eBay jobs are made here but started by server/index.js (and never under SHOES_DRILL);
+ * tests drive their runOnce().
+ */
+const path = require('path');
+const express = require('express');
+const helmet = require('helmet');
+const cookieParser = require('cookie-parser');
+const rateLimit = require('express-rate-limit');
+const contracts = require('openvibe-contracts');
+const cache = require('openvibe-shared/cache-policy');
+
+const configLib = require('./config');
+const { openStore } = require('./db');
+const { createKeyStore } = require('./auth/keys');
+const { createSso } = require('./auth/sso');
+const { createPrincipal } = require('./http/principal');
+const { createApi } = require('./http/api');
+const { createPageRoutes } = require('./http/pages');
+const { openapi } = require('./http/openapi');
+const { createServiceReadiness } = require('./observability');
+const { assetVersion, setRelease } = require('./render/layout');
+const { createCatalogue } = require('./catalogue');
+const { createUpstreams } = require('./upstreams');
+const { createJobs } = require('./jobs');
+
+const accountDataLib = require('./identity/account-data');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
+
+const PUBLIC_DIR = path.join(__dirname, '..', 'public');
+const VERSION = require('../package.json').version;
+const { createHttpTelemetry } = require('openvibe-sdk/service');
+const { createPgTelemetrySink } = require('openvibe-sdk/telemetry');
+
+/**
+ * opts: config, store, now (clock), fetchImpl (Network and the public upstreams), log, valkey, accountSend (a
+ * stand-in for Network's internal routes; tests only), telemetrySink.
+ */
+async function createApp(opts = {}) {
+ const config = opts.config || configLib.load();
+ const log = opts.log || console;
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
+ const s = opts.store || await openStore(config, { now: opts.now, log });
+
+ const keys = createKeyStore({ config, fetchImpl, log });
+ const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
+ const principal = createPrincipal({ config, keys });
+ const catalogue = createCatalogue(s, { staleHours: config.sources.staleHours });
+ const upstreams = createUpstreams({ config, fetchImpl, log, urlCheck: opts.urlCheck });
+ const jobs = createJobs({ config, catalogue, upstreams, log });
+
+ // Account export and deletion (ADR-033). The sender posts to Network's internal routes with this service's own
+ // client-credentials token; a test injects a stand-in through opts.accountSend.
+ const accountData = accountDataLib.create({ db: s.db, log });
+ const accountSend = opts.accountSend || (config.oauth.clientSecret
+ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
+ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Shoes cannot answer account events'); });
+
+ const ctx = { config, s, keys, sso, principal, catalogue, upstreams, jobs, accountData, accountSend, log };
+
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', config.trustProxy);
+ const release = require('openvibe-shared/release').createRelease({ service: 'shoes', root: path.join(__dirname, '..') });
+ setRelease(release.release);
+ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'shoes', release: release.release });
+ app.locals.metrics = metrics.registry;
+ ctx.telemetry = createHttpTelemetry({ service: 'shoes', sink: opts.telemetrySink || createPgTelemetrySink({ db: s.db, log }), skipPrefixes: ['/shared'], intervalMs: 60_000 });
+ app.use(ctx.telemetry.middleware());
+ app.locals.ctx = ctx;
+
+ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);
+ ctx.valkey = valkey;
+
+ app.use(contracts.http.middleware());
+ app.use(helmet({
+ contentSecurityPolicy: {
+ directives: {
+ defaultSrc: ["'self'"],
+ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
+ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
+ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
+ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media', 'https://static.nike.com'],
+ connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
+ frameSrc: ["'self'", 'https://openvibe.network'],
+ frameAncestors: ["'none'"],
+ objectSrc: ["'none'"],
+ baseUri: ["'self'"],
+ formAction: ["'self'", 'https://openvibe.network'],
+ },
+ },
+ frameguard: { action: 'deny' },
+ crossOriginEmbedderPolicy: false,
+ crossOriginResourcePolicy: { policy: 'same-site' },
+ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
+ }));
+ app.use(cookieParser());
+
+ // ── Machine endpoints ───────────────────────────────────
+ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-shoes', name: 'OpenVibe.Shoes', version: VERSION }));
+ release.mount(app, { registry: metrics.registry });
+ const readiness = createServiceReadiness({ s, config, release: release.release, valkey });
+ app.get('/api/ready', readiness.handler);
+
+ // ── Who is asking (verified offline; refreshed when expired) ──
+ app.use(sso.middleware());
+
+ // ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
+ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
+ app.use('/auth', sso.routes());
+ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'shoes', service: 'shoes', host: 'open.shoes', name: 'OpenVibe.Shoes', profile: 'ugc' })); }
+
+ // ── OpenVibe.Events → this service (loopback only) ──────
+ // Account export/deletion; the consumer reads the raw body itself, so it is mounted before any body parser.
+ // nginx answers 404 for /internal/.
+ app.use('/internal/events', accountData.consumer({
+ secrets: config.events.secrets,
+ send: accountSend,
+ now: s.now,
+ onEvent: async (ev) => `ignored:${ev.event_type}`,
+ }));
+
+ // ── Static assets (content-hashed ?v= → immutable) ──────
+ app.use('/shared', require('openvibe-shared/serve').handler());
+ app.use(express.static(PUBLIC_DIR, {
+ index: false, redirect: false,
+ setHeaders(res, filePath) {
+ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
+ const v = res.req && res.req.query && res.req.query.v;
+ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));
+ },
+ }));
+
+ // ── API ─────────────────────────────────────────────────
+ app.get('/api/openapi.json', (req, res) => res.json(openapi(config)));
+ app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.SHOES_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
+ app.use('/api/v1', createApi(ctx));
+ app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
+
+ // ── Pages ───────────────────────────────────────────────
+ app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.SHOES_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
+ app.use(createPageRoutes(ctx));
+ app.use((req, res) => sendNotFound(req, res, ctx));
+
+ // eslint-disable-next-line no-unused-vars
+ app.use((err, req, res, _next) => {
+ log.error('[OpenVibe.Shoes]', err && err.message ? err.message.slice(0, 300) : err);
+ if (res.headersSent) return;
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (err && err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });
+ if (err && err.type === 'entity.too.large') return req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov }) : res.status(413).type('text/plain').send('That request was too large.');
+ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
+ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
+ });
+
+ return { app, ctx };
+}
+
+function sendNotFound(req, res, ctx) {
+ const views = require('./views');
+ const { send } = require('./render/layout');
+ return send(res, 404, {
+ viewer: req.viewer, config: ctx.config, path: req.originalUrl, title: 'Not found',
+ body: views.notFound(), active: '/',
+ });
+}
+
+module.exports = { createApp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+
+/**
+ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured internal URL and
+ * kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline against it: the person's session token
+ * (sign-in) and a person's Bearer token (the API).
+ *
+ * keys.get() / keys.ensure() / keys.loaded() for readiness and the SSO middleware, and verification through the
+ * SDK's verifyUserToken / verifyAppToken with `jwks: <url>`. Nothing the SDK says about a failed key fetch leaves
+ * this module: token.no_key answers 'signing key not loaded yet'.
+ */
+const sdk = require('openvibe-sdk/auth');
+
+const KEY_UNAVAILABLE = 'signing key not loaded yet';
+const DOES_NOT_VERIFY = 'token does not verify';
+
+function publicReason(err) {
+ const code = err && err.code;
+ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;
+ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;
+}
+
+function jwksUrl(config) {
+ return (config.networkInternalUrl || config.networkUrl) + '/api/.well-known/jwks';
+}
+
+function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {
+ const url = jwksUrl(config);
+ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });
+ let mirror = null;
+
+ async function refreshMirror() {
+ try {
+ const ks = await client.keys();
+ const first = ks && ks[0];
+ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;
+ } catch (err) { log.warn(`[OpenVibe.Shoes] JWKS mirror refresh failed: ${err && err.message || err}`); mirror = null; }
+ }
+ async function ensure() { await refreshMirror(); }
+ function get() { return mirror ? mirror.pem : null; }
+ function loaded() { return Boolean(mirror); }
+
+ const asMs = (v) => (typeof v === 'function' ? v() : (typeof v === 'number' ? v : Date.now()));
+
+ async function verifyUser(token, { issuer, audience, now = Date.now() } = {}) {
+ try {
+ const claims = await sdk.verifyUserToken(token, { jwks: url, issuer, audience, now: asMs(now), log });
+ await refreshMirror();
+ return { ok: true, claims };
+ } catch (err) {
+ await refreshMirror();
+ const code = err && err.code;
+ log.warn(`[OpenVibe.Shoes] user token rejected (${code || 'error'}):`, (err && err.message) || err);
+ return { ok: false, reason: publicReason(err), code, expired: code === 'token.expired' };
+ }
+ }
+
+ async function verifyApp(token, { issuer, audience, acceptSandbox = true, now = Date.now() } = {}) {
+ try {
+ const claims = await sdk.verifyAppToken(token, { jwks: url, issuer, audience, acceptSandbox, now: asMs(now), log });
+ await refreshMirror();
+ return { ok: true, claims };
+ } catch (err) {
+ await refreshMirror();
+ const code = err && err.code;
+ log.warn(`[OpenVibe.Shoes] app token rejected (${code || 'error'}):`, (err && err.message) || err);
+ return { ok: false, reason: publicReason(err), code };
+ }
+ }
+
+ async function pemForToken(token) {
+ let kid = null;
+ try { kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { /* the guard refuses it */ }
+ try {
+ const ks = await client.keysForKid(kid);
+ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];
+ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;
+ } catch { return null; }
+ }
+
+ return { ensure, get, loaded, status: client.status, verifyUser, verifyApp, pemForToken, client };
+}
+
+module.exports = { createKeyStore, jwksUrl };
new file mode 100644
new file mode 100644
@@ -0,0 +1,260 @@
@@ -0,0 +1,260 @@
+'use strict';
+
+/**
+ * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `shoes`.
+ *
+ * GET /auth/login → Network /oauth/authorize?…&code_challenge=…&code_challenge_method=S256
+ * (?silent=1 adds prompt=none; ?next= a same-site path)
+ * GET /auth/callback → state check, server-side code exchange (client secret + code_verifier)
+ * GET /auth/logout → clear cookies, revoke the refresh token (best effort)
+ * GET /auth/me → { user } for the shared navbar; { user: null } for a guest
+ *
+ * This is Today's SSO module with the client id and cookie names changed. The person's Network access token lives
+ * in an httpOnly cookie (shoes_at) no script can read; the refresh token (shoes_rt) is httpOnly too. Neither is ever
+ * stored server-side, rendered or logged.
+ */
+const crypto = require('crypto');
+const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
+
+const ACCESS_COOKIE = 'shoes_at';
+const REFRESH_COOKIE = 'shoes_rt';
+const HINT_COOKIE = 'ov_sso_hint';
+const FLOW_COOKIE = 'shoes_oauth';
+
+function pkcePair() {
+ const verifier = crypto.randomBytes(32).toString('base64url');
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
+ return { verifier, challenge };
+}
+
+function ownOrigins(config) {
+ const out = new Set();
+ for (const u of [config && config.baseUrl, config && config.homes && config.homes.url]) {
+ try { if (u) out.add(new URL(u).origin); } catch { /* not a URL */ }
+ }
+ return out;
+}
+
+/** Same-site relative paths only (never protocol-relative, never another origin). */
+function sanitizeNext(next, config) {
+ if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
+ if (typeof next === 'string' && /^https?:\/\//i.test(next)) {
+ let u = null;
+ try { u = new URL(next); } catch { return '/'; }
+ if (!ownOrigins(config).has(u.origin)) return '/';
+ next = `${u.pathname}${u.search}${u.hash}`;
+ }
+ if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';
+ return next;
+}
+
+function authorizeUrl(config, { state, challenge, silent = false }) {
+ const q = new URLSearchParams({
+ response_type: 'code',
+ client_id: config.oauth.clientId,
+ redirect_uri: config.oauth.redirectUri,
+ scope: config.oauth.scope,
+ state,
+ code_challenge: challenge,
+ code_challenge_method: 'S256',
+ });
+ if (silent) q.set('prompt', 'none');
+ return `${config.networkUrl}/oauth/authorize?${q.toString()}`;
+}
+
+function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
+ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });
+
+ function setSession(res, data) {
+ res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });
+ if (data.refresh_token) res.cookie(REFRESH_COOKIE, data.refresh_token, { ...cookieBase(), maxAge: 30 * 24 * 60 * 60 * 1000 });
+ res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
+ }
+ function clearSession(res) {
+ res.clearCookie(ACCESS_COOKIE, cookieBase());
+ res.clearCookie(REFRESH_COOKIE, cookieBase());
+ }
+
+ async function tokenGrant(fields) {
+ const body = new URLSearchParams({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...fields });
+ let res;
+ try {
+ res = await fetchImpl(`${config.networkInternalUrl}/oauth/token`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
+ body, signal: AbortSignal.timeout(10_000),
+ });
+ } catch (err) {
+ throw Object.assign(new Error('OpenVibe.Network did not answer'), { status: 502, error: err && err.name === 'TimeoutError' ? 'timeout' : 'unreachable' });
+ }
+ const data = await res.json().catch(() => null);
+ if (!res.ok || !data || !data.access_token) {
+ throw Object.assign(new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`),
+ { status: res.status, error: (data && data.error) || 'invalid_grant' });
+ }
+ return data;
+ }
+
+ /**
+ * A Shoes session is a Network SESSION token: audience openvibe.network and a person. FedCM assertions,
+ * app/service tokens and internal tokens are signed with the same key and issuer; none may become a session here.
+ */
+ async function verifySession(token) {
+ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });
+ const c = v.claims;
+ if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };
+ return v;
+ }
+
+ function viewerFromClaims(claims, token) {
+ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;
+ const role = typeof claims.role === 'string' ? claims.role : 'user';
+ return {
+ kind: 'user',
+ subject,
+ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
+ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (typeof claims.username === 'string' ? claims.username.slice(0, 64) : 'you'),
+ role,
+ avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,
+ profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,
+ token,
+ };
+ }
+
+ async function refresh(req, res) {
+ if (!req.sessionRefresh) req.sessionRefresh = await doRefresh(req, res);
+ return req.sessionRefresh;
+ }
+
+ async function doRefresh(req, res) {
+ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
+ if (!rt) return null;
+ try {
+ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });
+ setSession(res, data);
+ const v = await verifySession(data.access_token);
+ return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;
+ } catch (err) {
+ if (err.status && err.status < 500) clearSession(res);
+ return null;
+ }
+ }
+
+ /** req.viewer for every page: the signed-in person, refreshed when their token expired. */
+ function middleware() {
+ return async (req, res, next) => {
+ req.viewer = { kind: 'anonymous' };
+ const token = req.cookies && req.cookies[ACCESS_COOKIE];
+ if (!token) return next();
+ await keys.ensure();
+ const v = await verifySession(token);
+ if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }
+ if (v.expired) {
+ const fresh = await refresh(req, res);
+ if (fresh) { req.viewer = fresh; return next(); }
+ }
+ if (keys.loaded()) clearSession(res);
+ next();
+ };
+ }
+
+ async function asViewer(req, res, fn) {
+ try {
+ return await fn(req.viewer.token);
+ } catch (err) {
+ if (!err || err.status !== 401) throw err;
+ const fresh = await refresh(req, res);
+ if (!fresh) throw err;
+ req.viewer = fresh;
+ return fn(fresh.token);
+ }
+ }
+
+ function routes() {
+ const r = express.Router();
+ const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });
+
+ r.get('/login', (req, res) => {
+ const silent = Boolean(req.query.silent) && req.query.silent !== '0';
+ const state = crypto.randomBytes(16).toString('hex');
+ const { verifier, challenge } = pkcePair();
+ res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next, config), silent }), flowOpts());
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ res.redirect(authorizeUrl(config, { state, challenge, silent }));
+ });
+
+ r.get('/callback', async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ let flow = null;
+ try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }
+ res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });
+ const next = flow ? sanitizeNext(flow.next, config) : '/';
+ const { code, state, error } = req.query;
+ if (error) {
+ if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);
+ return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);
+ }
+ if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'
+ || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {
+ return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');
+ }
+ if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');
+ try {
+ const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: config.oauth.redirectUri, code_verifier: flow.verifier });
+ setSession(res, data);
+ res.set('Clear-Site-Data', '"cache"');
+ return res.redirect(next);
+ } catch (err) {
+ log.warn('[OpenVibe.Shoes] sign-in exchange failed:', err.status || '', err.error || '');
+ return res.status(err.status && err.status < 500 ? 400 : 502).type('text/plain')
+ .send(err.status && err.status < 500 ? 'The Network refused the sign-in code. Please sign in again.' : 'Sign-in failed: OpenVibe.Network did not answer. Please try again.');
+ }
+ });
+
+ const sameOriginRequest = (req) => {
+ const site = String(req.get('sec-fetch-site') || '');
+ if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');
+ const origin = req.get('origin');
+ if (origin) return origin === `${req.protocol}://${req.get('host')}`;
+ return req.method === 'GET';
+ };
+ r.get('/logout', (req, res, next) => {
+ if (sameOriginRequest(req)) return next();
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next, config))}` : '';
+ res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`
+ + `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`
+ + `<p>Sign out of OpenVibe.Shoes?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);
+ });
+ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));
+ r.all('/logout', async (req, res) => {
+ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
+ if (rt) {
+ fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: rt }),
+ signal: AbortSignal.timeout(3000),
+ }).catch(() => {});
+ }
+ clearSession(res);
+ res.set('Clear-Site-Data', '"cache"');
+ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
+ res.redirect(303, sanitizeNext(req.query.next, config));
+ });
+
+ r.get('/me', (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ const c = req.cookies || {};
+ if (!c[ACCESS_COOKIE] && !c[REFRESH_COOKIE] && !req.get('authorization')) return res.json({ user: null });
+ const v = req.viewer;
+ if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });
+ res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role, avatar_url: v.avatarUrl || null, profile_color: v.profileColor || null } });
+ });
+ return r;
+ }
+
+ return { middleware, routes, asViewer, tokenGrant, pkcePair };
+}
+
+module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,580 @@
@@ -0,0 +1,580 @@
+'use strict';
+
+/**
+ * Shoes' catalogue: the models, the offers on them, the price history, the size-price curve, the drops, the cited
+ * facts and a person's watches. Everything the pages and the API show is read here; nothing is computed in the view.
+ *
+ * The rules that matter:
+ * - an offer keeps the time its source was checked (checked_at) and a stale flag (older than the hours allowed);
+ * - the price history keeps ONE snapshot per offer per day: the first check of that day wins, so a busy store
+ * cannot invent a curve by being checked often;
+ * - the all-in price is the price plus the shipping when the store states shipping, and the wire says whether
+ * the shipping was known;
+ * - a "drop" is a real saving the source stated (its list price against its current all-in price) or the fall
+ * between two of our own daily snapshots. It is never a made-up number;
+ * - a model is a brand and a model name; the page is /shoe/<brand>-<model>.
+ */
+const crypto = require('crypto');
+const parse = require('./domain/parse');
+
+const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
+const num = (v) => (v == null || v === '' ? null : (Number.isFinite(Number(v)) ? Number(v) : null));
+const money = (v) => (v == null ? null : Math.round(Number(v) * 100) / 100);
+
+/** The wire form of an offer: what the source said, and when. */
+function wireOffer(row, { staleHours = 48, now = Date.now() } = {}) {
+ if (!row) return null;
+ const price = num(row.price_amount);
+ const shipping = num(row.shipping_amount);
+ const list = num(row.list_price_amount);
+ const all = parse.allIn(price, shipping);
+ const drop = list != null && all.amount != null ? money(list - all.amount) : null;
+ return {
+ id: row.id,
+ model_id: row.model_id,
+ slug: row.slug || null,
+ store: row.store_domain,
+ store_name: row.store_name || null,
+ title: row.title,
+ url: row.url,
+ price,
+ list_price: list,
+ shipping,
+ shipping_known: shipping != null,
+ all_in: all.amount,
+ drop_from_list: drop != null && drop > 0 ? drop : null,
+ currency: row.currency || 'USD',
+ condition: row.condition || null,
+ size: row.size || null,
+ colourway: row.colourway || null,
+ checked_at: iso(row.checked_at),
+ stale: row.stale === true || parse.isStale(row.checked_at, now, staleHours),
+ source: row.source,
+ };
+}
+
+/** The wire form of a model with its numbers. */
+function wireModel(row, { now = Date.now() } = {}) {
+ if (!row) return null;
+ const priceMin = num(row.price_min);
+ const priceMax = num(row.price_max);
+ const drop = num(row.drop_amount);
+ return {
+ id: row.id,
+ slug: row.slug,
+ brand: row.brand,
+ model: row.model,
+ name: row.name,
+ price_min: priceMin,
+ price_max: priceMax,
+ offer_count: Number(row.offer_count) || 0,
+ store_count: Number(row.store_count) || 0,
+ stale_count: Number(row.stale_count) || 0,
+ checked_at: iso(row.checked_at),
+ drop_amount: drop != null && drop > 0 ? drop : null,
+ drop_percent: row.drop_percent != null ? num(row.drop_percent) : (drop != null && row.price_max != null && Number(row.price_max) > 0 ? Math.round((drop / (Number(row.price_max) + drop)) * 10000) / 100 : null),
+ first_seen_at: iso(row.first_seen_at),
+ updated_at: iso(row.updated_at),
+ };
+}
+
+function wireDrop(row) {
+ if (!row) return null;
+ let sizes = row.sizes;
+ if (typeof sizes === 'string') { try { sizes = JSON.parse(sizes); } catch { sizes = []; } }
+ return {
+ id: row.id,
+ slug: row.slug,
+ brand: row.brand,
+ model: row.model,
+ colourway: row.colourway || null,
+ release_at: iso(row.release_at),
+ price: num(row.price_amount),
+ currency: row.currency || 'USD',
+ sizes: Array.isArray(sizes) ? sizes.map(String) : [],
+ source_name: row.source_name,
+ source_url: row.source_url,
+ fetched_at: iso(row.fetched_at),
+ };
+}
+
+function wireWatch(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ kind: row.kind,
+ slug: row.slug || row.model_slug || null,
+ size: row.size || null,
+ condition: row.condition || null,
+ threshold: num(row.threshold_amount),
+ currency: row.currency || 'USD',
+ source_url: row.source_url || null,
+ remote_id: row.remote_id || null,
+ status: row.status,
+ note: row.note || null,
+ created_at: iso(row.created_at),
+ };
+}
+
+function shortHash(value) {
+ return crypto.createHash('sha256').update(String(value || '')).digest('hex').slice(0, 6);
+}
+
+function createCatalogue(s, { staleHours = 48 } = {}) {
+ const db = s.db;
+ const nowMs = () => s.now();
+
+ /** The model for a brand and a model name; it is created the first time it is seen. */
+ async function ensureModel({ brand, model, name }) {
+ const b = parse.cleanName(brand, 80);
+ const m = parse.cleanName(model, 120);
+ if (!b || !m) return null;
+ const slug = parse.slugFor(b, m);
+ if (!slug) return null;
+ const existing = await db.maybe('SELECT * FROM shoe_models WHERE slug = $1', [slug]);
+ if (existing) return existing;
+ try {
+ return await db.one(
+ `INSERT INTO shoe_models (id, slug, brand, model, name, first_seen_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $6::timestamptz)
+ ON CONFLICT (slug) DO UPDATE SET updated_at = EXCLUDED.updated_at
+ RETURNING *`,
+ [s.newId('shm'), slug, b, m, parse.cleanName(name || m, 120), s.iso()],
+ );
+ } catch (err) {
+ // A concurrent importer inserted the same model between the SELECT and the INSERT.
+ const again = await db.maybe('SELECT * FROM shoe_models WHERE slug = $1', [slug]);
+ if (again) return again;
+ throw err;
+ }
+ }
+
+ /**
+ * One offer from a source. The model is created from the product name and the source's brand; the snapshot for
+ * today is written only when there is none yet (one per offer per day).
+ */
+ async function upsertOffer(input) {
+ const title = parse.cleanName(input.title, 200);
+ if (!title) return null;
+ const modelTitle = parse.cleanName(input.modelName || title, 200);
+ const brand = parse.brandOf(modelTitle, input.brand);
+ const model = parse.modelOf(modelTitle, brand);
+ if (!brand || !model) return null;
+ const modelRow = await ensureModel({ brand, model, name: modelTitle });
+ if (!modelRow) return null;
+
+ const price = parse.amountOf(input.price);
+ const listPrice = parse.amountOf(input.listPrice);
+ const shipping = parse.amountOf(input.shipping);
+ const checkedAt = input.checkedAt instanceof Date ? input.checkedAt : new Date(input.checkedAt || s.now());
+ if (Number.isNaN(checkedAt.getTime())) return null;
+ const condition = parse.conditionOf(input.condition);
+ const size = parse.cleanSize(input.size || parse.sizeOf(title));
+ const stale = parse.isStale(checkedAt, nowMs(), staleHours);
+ const sourceId = String(input.sourceId || '').trim();
+ if (!sourceId) return null;
+ const url = String(input.url || '');
+ if (!/^https?:\/\//i.test(url)) return null;
+ const store = String(input.store || '').toLowerCase().slice(0, 120);
+ if (!store) return null;
+
+ const row = await db.one(
+ `INSERT INTO shoe_offers
+ (id, model_id, source, source_id, store_domain, store_name, title, url, price_amount, list_price_amount,
+ currency, shipping_amount, condition, size, colourway, checked_at, stale, active, raw, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::timestamptz, $17, true, $18::jsonb, $19::timestamptz, $19::timestamptz)
+ ON CONFLICT (source, source_id) DO UPDATE SET
+ model_id = EXCLUDED.model_id, store_name = EXCLUDED.store_name, title = EXCLUDED.title, url = EXCLUDED.url,
+ price_amount = EXCLUDED.price_amount, list_price_amount = EXCLUDED.list_price_amount,
+ currency = EXCLUDED.currency, shipping_amount = EXCLUDED.shipping_amount,
+ condition = EXCLUDED.condition, size = EXCLUDED.size, colourway = EXCLUDED.colourway,
+ checked_at = EXCLUDED.checked_at, stale = EXCLUDED.stale, active = true, raw = EXCLUDED.raw,
+ updated_at = EXCLUDED.updated_at
+ RETURNING *`,
+ [
+ s.newId('sho'), modelRow.id, String(input.source || 'deals'), sourceId, store,
+ input.storeName || null, title, url, price, listPrice, String(input.currency || 'USD'), shipping,
+ condition, size, parse.cleanName(input.colourway, 60) || null,
+ checkedAt.toISOString(), stale, JSON.stringify(input.raw || {}), s.iso(),
+ ],
+ );
+ if (!row) return null;
+ await writeSnapshot(row, price, checkedAt);
+ return row;
+ }
+
+ /** One snapshot per offer per day: the first check of that day wins. */
+ async function writeSnapshot(offer, price, checkedAt) {
+ const day = parse.dayOf(checkedAt);
+ if (!day) return null;
+ return await db.maybe(
+ `INSERT INTO shoe_snapshots (id, offer_id, model_id, price_amount, shipping_amount, checked_at, day, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::date, $8::timestamptz)
+ ON CONFLICT (offer_id, day) DO NOTHING
+ RETURNING *`,
+ [s.newId('shs'), offer.id, offer.model_id, price, num(offer.shipping_amount), checkedAt.toISOString(), day, s.iso()],
+ );
+ }
+
+ /** The models, with their numbers: the filters, the sort and the facets. */
+ async function listShoes({ q = null, brand = null, size = null, condition = null, store = null, sort = 'recent', limit = 24, offset = 0 } = {}) {
+ const args = [];
+ const where = [];
+ if (q) {
+ args.push(`%${String(q).trim()}%`);
+ where.push(`(m.name ILIKE $${args.length} OR m.brand ILIKE $${args.length} OR m.model ILIKE $${args.length})`);
+ }
+ if (brand) { args.push(String(brand)); where.push(`m.brand = $${args.length}`); }
+ if (size) {
+ args.push(String(size));
+ where.push(`EXISTS (SELECT 1 FROM shoe_offers o WHERE o.model_id = m.id AND o.active AND o.size = $${args.length})`);
+ }
+ if (condition) {
+ args.push(String(condition));
+ where.push(`EXISTS (SELECT 1 FROM shoe_offers o WHERE o.model_id = m.id AND o.active AND o.condition = $${args.length})`);
+ }
+ if (store) {
+ args.push(String(store));
+ where.push(`EXISTS (SELECT 1 FROM shoe_offers o WHERE o.model_id = m.id AND o.active AND o.store_domain = $${args.length})`);
+ }
+ const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
+ const order = {
+ price: 'price_min ASC NULLS LAST, m.name ASC',
+ price_desc: 'price_max DESC NULLS LAST, m.name ASC',
+ recent: 'checked_at DESC NULLS LAST, m.name ASC',
+ drop: 'drop_amount DESC NULLS LAST, checked_at DESC NULLS LAST',
+ popular: 'offer_count DESC NULLS LAST, checked_at DESC NULLS LAST',
+ name: 'm.brand ASC, m.model ASC',
+ }[sort] || 'checked_at DESC NULLS LAST, m.name ASC';
+ const staleParam = args.length + 1;
+ args.push(staleHours);
+ const limitParam = args.length + 1;
+ args.push(Math.max(1, Math.min(100, limit)));
+ const offsetParam = args.length + 1;
+ args.push(Math.max(0, offset));
+ const rows = await db.many(
+ `SELECT m.*,
+ (SELECT MIN(o.price_amount + COALESCE(o.shipping_amount, 0)) FROM shoe_offers o WHERE o.model_id = m.id AND o.active AND o.price_amount IS NOT NULL) AS price_min,
+ (SELECT MAX(o.price_amount + COALESCE(o.shipping_amount, 0)) FROM shoe_offers o WHERE o.model_id = m.id AND o.active AND o.price_amount IS NOT NULL) AS price_max,
+ (SELECT COUNT(*)::int FROM shoe_offers o WHERE o.model_id = m.id AND o.active) AS offer_count,
+ (SELECT COUNT(DISTINCT o.store_domain)::int FROM shoe_offers o WHERE o.model_id = m.id AND o.active) AS store_count,
+ (SELECT COUNT(*) FILTER (WHERE o.stale OR o.checked_at < now() - ($${staleParam} || ' hours')::interval)::int FROM shoe_offers o WHERE o.model_id = m.id AND o.active) AS stale_count,
+ (SELECT MAX(o.checked_at) FROM shoe_offers o WHERE o.model_id = m.id AND o.active) AS checked_at,
+ (SELECT MAX(GREATEST(
+ COALESCE(o.list_price_amount - (o.price_amount + COALESCE(o.shipping_amount, 0)), 0),
+ COALESCE((
+ SELECT x.prev_price - x.price_amount
+ FROM (
+ SELECT s.price_amount, LAG(s.price_amount) OVER (ORDER BY s.day) AS prev_price,
+ ROW_NUMBER() OVER (ORDER BY s.day DESC) AS rn
+ FROM shoe_snapshots s WHERE s.offer_id = o.id
+ ) x WHERE x.rn = 1
+ ), 0)
+ )) FROM shoe_offers o WHERE o.model_id = m.id AND o.active AND o.price_amount IS NOT NULL) AS drop_amount
+ FROM shoe_models m
+ ${clause}
+ ORDER BY ${order}
+ LIMIT $${limitParam} OFFSET $${offsetParam}`,
+ args,
+ );
+ return rows.map((r) => wireModel(r, { now: nowMs() }));
+ }
+
+ /** How many models the same filters match, for a page's "next" link. */
+ async function countShoes({ q = null, brand = null, size = null, condition = null, store = null } = {}) {
+ const args = [];
+ const where = [];
+ if (q) {
+ args.push(`%${String(q).trim()}%`);
+ where.push(`(m.name ILIKE $${args.length} OR m.brand ILIKE $${args.length} OR m.model ILIKE $${args.length})`);
+ }
+ if (brand) { args.push(String(brand)); where.push(`m.brand = $${args.length}`); }
+ if (size) { args.push(String(size)); where.push(`EXISTS (SELECT 1 FROM shoe_offers o WHERE o.model_id = m.id AND o.active AND o.size = $${args.length})`); }
+ if (condition) { args.push(String(condition)); where.push(`EXISTS (SELECT 1 FROM shoe_offers o WHERE o.model_id = m.id AND o.active AND o.condition = $${args.length})`); }
+ if (store) { args.push(String(store)); where.push(`EXISTS (SELECT 1 FROM shoe_offers o WHERE o.model_id = m.id AND o.active AND o.store_domain = $${args.length})`); }
+ const row = await db.one(`SELECT COUNT(*)::int AS n FROM shoe_models m ${where.length ? `WHERE ${where.join(' AND ')}` : ''}`, args);
+ return Number(row && row.n) || 0;
+ }
+
+ /** The facets of the whole catalogue: the brands, the sizes, the conditions, the stores and the price range. */
+ async function facets() {
+ const [brands, sizes, conditions, stores, prices, checked, counts] = await Promise.all([
+ db.many('SELECT m.brand AS value, COUNT(DISTINCT m.id)::int AS count FROM shoe_models m GROUP BY m.brand ORDER BY count DESC, value ASC'),
+ db.many('SELECT o.size AS value, COUNT(*)::int AS count FROM shoe_offers o WHERE o.active AND o.size IS NOT NULL GROUP BY o.size ORDER BY count DESC, value ASC'),
+ db.many('SELECT o.condition AS value, COUNT(*)::int AS count FROM shoe_offers o WHERE o.active AND o.condition IS NOT NULL GROUP BY o.condition ORDER BY count DESC, value ASC'),
+ db.many('SELECT o.store_domain AS value, COUNT(*)::int AS count FROM shoe_offers o WHERE o.active GROUP BY o.store_domain ORDER BY count DESC, value ASC'),
+ db.many('SELECT MIN(o.price_amount) AS min, MAX(o.price_amount) AS max FROM shoe_offers o WHERE o.active AND o.price_amount IS NOT NULL'),
+ db.many('SELECT (SELECT MAX(x.checked_at) FROM shoe_offers x WHERE x.active) AS checked_at, COUNT(*) FILTER (WHERE o.stale OR o.checked_at < now() - ($1 || \' hours\')::interval)::int AS stale FROM shoe_offers o WHERE o.active', [staleHours]),
+ db.many('SELECT (SELECT COUNT(*)::int FROM shoe_models) AS models, (SELECT COUNT(*)::int FROM shoe_offers WHERE active) AS offers, (SELECT COUNT(*)::int FROM shoe_drops WHERE release_at > now()) AS drops'),
+ ]);
+ const c = counts[0] || { models: 0, offers: 0, drops: 0 };
+ return {
+ brands: brands.map((b) => ({ value: b.value, count: Number(b.count) || 0 })),
+ sizes: sizes.map((b) => ({ value: b.value, count: Number(b.count) || 0 })),
+ conditions: conditions.map((b) => ({ value: b.value, count: Number(b.count) || 0 })),
+ stores: stores.map((b) => ({ value: b.value, count: Number(b.count) || 0 })),
+ price_min: num(prices[0] ? prices[0].min : null),
+ price_max: num(prices[0] ? prices[0].max : null),
+ checked_at: checked[0] ? iso(checked[0].checked_at) : null,
+ stale_offers: Number(checked[0] ? checked[0].stale : 0) || 0,
+ models: Number(c.models) || 0,
+ offers: Number(c.offers) || 0,
+ drops: Number(c.drops) || 0,
+ };
+ }
+
+ /** One model. */
+ async function modelBySlug(slug) {
+ return (await db.maybe('SELECT * FROM shoe_models WHERE slug = $1', [String(slug || '')])) || null;
+ }
+
+ async function modelById(id) {
+ if (!id) return null;
+ return (await db.maybe('SELECT * FROM shoe_models WHERE id = $1', [String(id)])) || null;
+ }
+
+ /** The offers of a model, cheapest all-in first. */
+ async function offersOf(modelId) {
+ const rows = await db.many(
+ `SELECT o.*, m.slug FROM shoe_offers o JOIN shoe_models m ON m.id = o.model_id
+ WHERE o.model_id = $1 AND o.active
+ ORDER BY (o.price_amount + COALESCE(o.shipping_amount, 0)) ASC NULLS LAST, o.checked_at DESC`,
+ [modelId],
+ );
+ return rows.map((r) => wireOffer(r, { staleHours, now: nowMs() }));
+ }
+
+ /** The price history of a model: one point per day, the lowest all-in price of that day. */
+ async function history(modelId, { days = 90 } = {}) {
+ const rows = await db.many(
+ `SELECT s.day, MIN(s.price_amount + COALESCE(s.shipping_amount, 0)) AS price, COUNT(*)::int AS offers, MAX(s.checked_at) AS checked_at
+ FROM shoe_snapshots s
+ WHERE s.model_id = $1 AND s.price_amount IS NOT NULL
+ AND s.day >= (CURRENT_DATE - ($2::int || ' days')::interval)
+ GROUP BY s.day ORDER BY s.day ASC`,
+ [modelId, Math.max(1, Math.min(365, days))],
+ );
+ return rows.map((r) => ({
+ day: iso(r.day).slice(0, 10),
+ price: num(r.price),
+ offers: Number(r.offers) || 0,
+ checked_at: iso(r.checked_at),
+ }));
+ }
+
+ /** The size-price curve: the lowest all-in price per size, and what it costs against the model's middle. */
+ async function curve(modelId) {
+ const rows = await db.many(
+ `SELECT o.size AS size,
+ MIN(o.price_amount + COALESCE(o.shipping_amount, 0)) AS min_price,
+ AVG(o.price_amount + COALESCE(o.shipping_amount, 0)) AS avg_price,
+ COUNT(*)::int AS offers,
+ COUNT(DISTINCT o.store_domain)::int AS stores,
+ MAX(o.checked_at) AS checked_at
+ FROM shoe_offers o
+ WHERE o.model_id = $1 AND o.active AND o.size IS NOT NULL AND o.price_amount IS NOT NULL
+ GROUP BY o.size`,
+ [modelId],
+ );
+ const points = rows.map((r) => ({
+ size: r.size,
+ min_price: num(r.min_price),
+ avg_price: num(r.avg_price),
+ offers: Number(r.offers) || 0,
+ stores: Number(r.stores) || 0,
+ checked_at: iso(r.checked_at),
+ })).sort((a, b) => a.size.localeCompare(b.size, 'en', { numeric: true }));
+ if (!points.length) return { points: [], median: null };
+ const prices = points.map((p) => p.min_price).filter((p) => p != null).sort((a, b) => a - b);
+ const mid = prices.length ? prices[Math.floor((prices.length - 1) / 2)] : null;
+ return {
+ points: points.map((p) => ({
+ ...p,
+ vs_median_percent: mid && p.min_price != null ? Math.round((p.min_price - mid) * 10000) / 100 : null,
+ })),
+ median: mid,
+ };
+ }
+
+ /** The drops, soonest first. */
+ async function listDrops({ from = null, to = null, limit = 60 } = {}) {
+ const args = [];
+ const where = [];
+ if (from) { args.push(new Date(from).toISOString()); where.push(`d.release_at >= $${args.length}::timestamptz`); }
+ if (to) { args.push(new Date(to).toISOString()); where.push(`d.release_at <= $${args.length}::timestamptz`); }
+ const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
+ args.push(Math.max(1, Math.min(200, limit)));
+ const rows = await db.many(`SELECT d.* FROM shoe_drops d ${clause} ORDER BY d.release_at ASC LIMIT $${args.length}`, args);
+ return rows.map(wireDrop);
+ }
+
+ /** The drops a source published in the last read, and one of them by its page id. */
+ async function dropById(id) {
+ const row = await db.maybe('SELECT * FROM shoe_drops WHERE id = $1', [String(id || '')]);
+ return row ? wireDrop(row) : null;
+ }
+
+ /** The official releases that match a model, soonest first (a cited release date on its page). */
+ async function dropsForModel(model, { limit = 5 } = {}) {
+ if (!model) return [];
+ const rows = await db.many(
+ `SELECT * FROM shoe_drops
+ WHERE lower(brand) = lower($1) AND lower(model) = lower($2)
+ AND release_at >= now() - interval '1 day'
+ ORDER BY release_at ASC LIMIT $3`,
+ [model.brand, model.model, Math.max(1, Math.min(20, limit))],
+ );
+ return rows.map(wireDrop);
+ }
+
+ /** One drop from an official release page. */
+ async function upsertDrop(input) {
+ const brand = parse.cleanName(input.brand, 80);
+ const model = parse.cleanName(input.model, 120);
+ if (!brand || !model) return null;
+ const sourceKey = String(input.sourceKey || '').trim();
+ if (!sourceKey) return null;
+ const sourceUrl = String(input.sourceUrl || '');
+ if (!/^https?:\/\//i.test(sourceUrl)) return null;
+ const base = parse.slugFor(brand, `${model} ${input.colourway || ''}`) || parse.slugFor(brand, model);
+ const slug = `${base.slice(0, 72)}-${shortHash(sourceKey)}`.slice(0, 80).replace(/-$/, '');
+ const row = await db.one(
+ `INSERT INTO shoe_drops
+ (id, slug, brand, model, colourway, release_at, price_amount, currency, sizes, source_name, source_url, source_key, fetched_at, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7, $8, $9::jsonb, $10, $11, $12, $13::timestamptz, $14::timestamptz, $14::timestamptz)
+ ON CONFLICT (source_name, source_key) DO UPDATE SET
+ release_at = EXCLUDED.release_at, price_amount = EXCLUDED.price_amount, currency = EXCLUDED.currency,
+ sizes = EXCLUDED.sizes, colourway = EXCLUDED.colourway, source_url = EXCLUDED.source_url,
+ fetched_at = EXCLUDED.fetched_at, updated_at = EXCLUDED.updated_at
+ RETURNING *`,
+ [
+ s.newId('shd'), slug, brand, model, parse.cleanName(input.colourway, 60) || null,
+ new Date(input.releaseAt).toISOString(), parse.amountOf(input.price), String(input.currency || 'USD'),
+ JSON.stringify(Array.isArray(input.sizes) ? input.sizes : []),
+ String(input.sourceName || 'official release page'), sourceUrl, sourceKey,
+ new Date(input.fetchedAt || s.now()).toISOString(), s.iso(),
+ ],
+ );
+ return row ? wireDrop(row) : null;
+ }
+
+ /** A cited fact on a model. Only a source's own words; no fact without its URL. */
+ async function addFact({ modelId, kind, label, value, sourceName, sourceUrl, fetchedAt }) {
+ const k = String(kind || '');
+ if (!['specs', 'fit', 'release', 'care'].includes(k)) return null;
+ if (!/^https?:\/\//i.test(String(sourceUrl || ''))) return null;
+ const row = await db.one(
+ `INSERT INTO shoe_facts (id, model_id, kind, label, value, source_name, source_url, fetched_at, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::timestamptz, $9::timestamptz) RETURNING *`,
+ [s.newId('shf'), modelId, k, parse.cleanName(label, 80), String(value).slice(0, 500),
+ parse.cleanName(sourceName, 120), sourceUrl, new Date(fetchedAt || s.now()).toISOString(), s.iso()],
+ );
+ return row || null;
+ }
+
+ async function factsOf(modelId) {
+ const rows = await db.many('SELECT * FROM shoe_facts WHERE model_id = $1 ORDER BY kind ASC, label ASC', [modelId]);
+ return rows.map((r) => ({
+ kind: r.kind, label: r.label, value: r.value,
+ source_name: r.source_name, source_url: r.source_url, fetched_at: iso(r.fetched_at),
+ }));
+ }
+
+ /** A person's watch: the price watch or the pasted listing. */
+ async function createWatch(input) {
+ const row = await db.one(
+ `INSERT INTO shoe_watches
+ (id, subject, kind, model_id, slug, size, condition, threshold_amount, currency, source_url, remote_id, status, note, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::timestamptz, $14::timestamptz)
+ RETURNING *`,
+ [
+ s.newId('shw'), String(input.subject), String(input.kind), input.modelId || null, input.slug || null,
+ input.size || null, input.condition || null, parse.amountOf(input.threshold), String(input.currency || 'USD'),
+ input.sourceUrl || null, input.remoteId || null, String(input.status || 'active'), input.note || null, s.iso(),
+ ],
+ );
+ return wireWatch(row);
+ }
+
+ /** A person's watches, newest first. */
+ async function listWatches(subject) {
+ const rows = await db.many(
+ `SELECT w.*, m.slug AS model_slug FROM shoe_watches w
+ LEFT JOIN shoe_models m ON m.id = w.model_id
+ WHERE w.subject = $1 ORDER BY w.created_at DESC`,
+ [String(subject)],
+ );
+ return rows.map(wireWatch);
+ }
+
+ /** The lowest all-in price for a model, optionally for one size, condition and store (the price a Watch reads). */
+ async function priceFor(modelId, { size = null, condition = null } = {}) {
+ const args = [modelId];
+ const where = ['o.model_id = $1', 'o.active', 'o.price_amount IS NOT NULL'];
+ if (size) { args.push(String(size)); where.push(`o.size = $${args.length}`); }
+ if (condition) { args.push(String(condition)); where.push(`o.condition = $${args.length}`); }
+ const row = await db.one(
+ `SELECT MIN(o.price_amount + COALESCE(o.shipping_amount, 0)) AS all_in,
+ MAX(o.checked_at) AS checked_at, COUNT(*)::int AS offers
+ FROM shoe_offers o WHERE ${where.join(' AND ')}`,
+ args,
+ );
+ return { price: num(row && row.all_in), checked_at: row ? iso(row.checked_at) : null, offers: Number((row && row.offers) || 0) };
+ }
+
+ /** Record what a job did; the pages and /api/ready use it for an honest "last checked". */
+ async function startJobRun({ job, sourceName, sourceUrl }) {
+ const row = await db.one(
+ `INSERT INTO shoe_job_runs (job, source_name, source_url, started_at)
+ VALUES ($1, $2, $3, $4::timestamptz) RETURNING id`,
+ [String(job), String(sourceName || job), sourceUrl || null, s.iso()],
+ );
+ return row ? row.id : null;
+ }
+ async function finishJobRun(id, { ok = true, counts = {}, error = null } = {}) {
+ if (id == null) return;
+ await db.exec(
+ `UPDATE shoe_job_runs SET finished_at = $1::timestamptz, ok = $2, counts = $3::jsonb, error = $4 WHERE id = $5`,
+ [s.iso(), !!ok, JSON.stringify(counts || {}), error ? String(error).slice(0, 500) : null, id],
+ );
+ }
+ async function lastJobRuns() {
+ const rows = await db.many(
+ `SELECT DISTINCT ON (job) job, source_name, source_url, started_at, finished_at, ok, counts, error
+ FROM shoe_job_runs ORDER BY job, started_at DESC`,
+ );
+ return rows.map((r) => ({
+ job: r.job, source_name: r.source_name, source_url: r.source_url,
+ started_at: iso(r.started_at), finished_at: iso(r.finished_at), ok: r.ok,
+ counts: typeof r.counts === 'string' ? JSON.parse(r.counts) : r.counts, error: r.error || null,
+ }));
+ }
+
+ /** The number of rows a table holds (the seed report and the tests). */
+ async function counts() {
+ const row = await db.one(
+ `SELECT (SELECT COUNT(*)::int FROM shoe_models) AS models,
+ (SELECT COUNT(*)::int FROM shoe_offers) AS offers,
+ (SELECT COUNT(*)::int FROM shoe_snapshots) AS snapshots,
+ (SELECT COUNT(*)::int FROM shoe_drops) AS drops,
+ (SELECT COUNT(*)::int FROM shoe_watches) AS watches,
+ (SELECT COUNT(*)::int FROM shoe_facts) AS facts`,
+ );
+ return {
+ models: Number(row.models) || 0,
+ offers: Number(row.offers) || 0,
+ snapshots: Number(row.snapshots) || 0,
+ drops: Number(row.drops) || 0,
+ watches: Number(row.watches) || 0,
+ facts: Number(row.facts) || 0,
+ };
+ }
+
+ return {
+ ensureModel, upsertOffer, writeSnapshot, listShoes, countShoes, facets, modelBySlug, modelById, offersOf, history,
+ curve, listDrops, dropById, dropsForModel, upsertDrop, addFact, factsOf, createWatch, listWatches, priceFor,
+ startJobRun, finishJobRun, lastJobRuns, counts, wireOffer, wireModel, wireDrop, wireWatch,
+ };
+}
+
+module.exports = { createCatalogue, wireOffer, wireModel, wireDrop, wireWatch };
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -0,0 +1,123 @@
+'use strict';
+
+/**
+ * OpenVibe.Shoes configuration (open.shoes). Every value comes from the environment (production:
+ * /etc/openvibe/shoes.env, see .env.example). Only environment variable NAMES appear in code and docs; secrets are
+ * never logged.
+ *
+ * load(env) is pure so tests can build a config without touching process.env.
+ */
+require('dotenv').config();
+const trim = (s) => String(s || '').replace(/\/+$/, '');
+const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
+const bool = (v, def = false) => (v == null ? def : v === '1' || v === 'true' || v === 'yes');
+
+function load(env = process.env) {
+ const nodeEnv = env.NODE_ENV || 'development';
+ const isProduction = nodeEnv === 'production';
+ const port = int(env.PORT, 5180);
+ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://open.shoes' : `http://localhost:${port}`));
+ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
+
+ return {
+ service: 'shoes',
+ room: 'shoes',
+ port,
+ host: env.HOST || '127.0.0.1',
+ nodeEnv,
+ isProduction,
+ baseUrl,
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
+ drill: bool(env.SHOES_DRILL),
+ limits: {
+ minute: Math.max(1, int(env.SHOES_LIMITS_MINUTE, 120)),
+ hour: Math.max(1, int(env.SHOES_LIMITS_HOUR, 3000)),
+ },
+
+ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role). In
+ // development without DATABASE_URL an embedded PGlite database in data/pglite is used (SHOES_PGLITE_DIR
+ // overrides the directory).
+ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.SHOES_PGLITE_DIR || '' },
+ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:shoes:' },
+
+ // OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
+ networkUrl,
+ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
+ networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
+ audience: env.SHOES_AUDIENCE || 'open.shoes',
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'shoes',
+ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
+ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
+ scope: 'profile',
+ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
+ },
+ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
+
+ // OpenVibe.Events → this service's account export/deletion route. The secret signs a delivery
+ // (comma-separated for rotation, 32+ characters each); unset turns POST /internal/events off (503).
+ events: {
+ secrets: String(env.SHOES_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
+ url: trim(env.SHOES_EVENTS_URL || env.EVENTS_URL || ''),
+ endpoint: env.SHOES_EVENTS_ENDPOINT || '',
+ intervalMs: Math.max(500, int(env.SHOES_EVENTS_INTERVAL_MS, 5000)),
+ scope: env.SHOES_EVENTS_SCOPE || 'events.event.publish',
+ },
+
+ // The sources the jobs read. OpenVibe.Deals is the network's own offer aggregator (its public API); the drop
+ // source is a brand's or a store's own public release page. eBay's official Browse API is used only when a
+ // key pair is set. No key, no source: that part is off with a plain note, never an error.
+ sources: {
+ dealsUrl: trim(env.SHOES_DEALS_URL || 'https://openvibe.deals'),
+ offersPages: Math.max(1, Math.min(200, int(env.SHOES_OFFERS_PAGES, 20))),
+ productLookups: Math.max(0, Math.min(500, int(env.SHOES_PRODUCT_LOOKUPS, 300))),
+ dropsUrl: trim(env.SHOES_DROPS_URL || 'https://www.nike.com/launch'),
+ dropsSourceName: env.SHOES_DROPS_SOURCE_NAME || 'Nike SNKRS launch calendar',
+ dropFeeds: (() => {
+ const raw = String(env.SHOES_DROP_FEEDS || '').trim();
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed.filter((x) => x && x.url) : [];
+ } catch { return []; }
+ })(),
+ staleHours: Math.max(1, int(env.SHOES_STALE_HOURS, 48)),
+ timeoutMs: Math.max(500, int(env.SHOES_UPSTREAM_TIMEOUT_MS, 15000)),
+ requestGapMs: Math.max(0, int(env.SHOES_REQUEST_GAP_MS, 250)),
+ ebay: {
+ clientId: env.SHOES_EBAY_CLIENT_ID || '',
+ clientSecret: env.SHOES_EBAY_CLIENT_SECRET || '',
+ marketplace: env.SHOES_EBAY_MARKETPLACE || 'EBAY_US',
+ apiUrl: trim(env.SHOES_EBAY_API_URL || 'https://api.ebay.com'),
+ limit: Math.max(1, Math.min(200, int(env.SHOES_EBAY_LIMIT, 50))),
+ categoryId: env.SHOES_EBAY_CATEGORY_ID || '93427',
+ },
+ },
+
+ // OpenVibe.Watch: the watch service that carries the alerts. Without a service token the watch is saved
+ // here and marked "not connected" — a plain note, never an error.
+ watch: {
+ url: trim(env.SHOES_WATCH_URL || 'https://openvibe.watch'),
+ internalUrl: trim(env.SHOES_WATCH_INTERNAL_URL || 'http://127.0.0.1:4730'),
+ token: env.SHOES_WATCH_TOKEN || '',
+ audience: env.SHOES_WATCH_AUDIENCE || 'openvibe.watch',
+ capability: env.SHOES_WATCH_CAPABILITY || 'watch.watch.manage',
+ everySec: Math.max(900, int(env.SHOES_WATCH_EVERY_SEC, 21600)),
+ },
+
+ // OpenVibe.Today: the drops calendar's reminders.
+ today: {
+ url: trim(env.SHOES_TODAY_URL || 'https://openvibe.today'),
+ internalUrl: trim(env.SHOES_TODAY_INTERNAL_URL || 'http://127.0.0.1:5100'),
+ },
+
+ // The jobs. Each refuses to start under SHOES_DRILL; tests call their runOnce().
+ jobs: {
+ offersIntervalMs: Math.max(60_000, int(env.SHOES_OFFERS_INTERVAL_MS, 30 * 60_000)),
+ dropsIntervalMs: Math.max(60_000, int(env.SHOES_DROPS_INTERVAL_MS, 6 * 60 * 60_000)),
+ ebayIntervalMs: Math.max(60_000, int(env.SHOES_EBAY_INTERVAL_MS, 60 * 60_000)),
+ },
+ };
+}
+
+module.exports = { load };
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+'use strict';
+
+/**
+ * OpenVibe.Shoes's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.
+ * Nothing written here is ever a secret. An offer stores the store's own URL, the price and the time it was
+ * checked; a watch stores a person's subject id, a threshold and the public URL they pasted.
+ */
+const fs = require('fs');
+const path = require('path');
+const { createDb } = require('openvibe-sdk/db');
+const { ids } = require('openvibe-contracts');
+
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
+
+async function openDb(config, { log = console, registry } = {}) {
+ if (!config.db.url) {
+ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh today)');
+ const dir = config.db.pgliteDir || DEV_PGLITE;
+ log.warn(`[OpenVibe.Shoes] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
+ fs.mkdirSync(dir, { recursive: true });
+ const db = createDb({ pglite: dir, service: 'shoes', registry, log });
+ await db.migrate({ dir: MIGRATIONS, log });
+ return db;
+ }
+ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
+ const owner = createDb({ url: config.db.directUrl, service: 'shoes-migrate', max: 1, log });
+ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+ return createDb({ url: config.db.url, service: 'shoes', registry, log });
+}
+
+/** The store every module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */
+function createStore(db, { now = () => Date.now() } = {}) {
+ return {
+ db,
+ now,
+ iso: () => new Date(now()).toISOString(),
+ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,
+ tx: async (fn) => await db.tx((t) => fn(t)),
+ close: () => db.close(),
+ };
+}
+
+async function openStore(config, { now, log } = {}) {
+ return createStore(await openDb(config, { log }), { now });
+}
+
+module.exports = { openDb, openStore, createStore, MIGRATIONS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,144 @@
@@ -0,0 +1,144 @@
+'use strict';
+
+/**
+ * The drops a brand or a store publishes on its own release page. The parser is pure: it takes the page's HTML
+ * and returns the releases it can prove, each with the time it was read. It never invents a date: a release with
+ * no date in the page is not returned at all.
+ *
+ * The page this reads is a Nike SNKRS launch calendar (the brand's own public release page). Its data arrives as
+ * a JSON payload inside the page; the rules below read the fields Shoes shows: the release moment, the model, the
+ * colourway, the price and the size grid. A second source can be a plain RSS feed of a brand's or a store's own
+ * releases.
+ */
+const parse = require('./parse');
+
+const BRAND_RULES = [
+ { brand: 'Jordan', test: /^(air\s+)?jordan\b/i },
+ { brand: 'Nike', test: /^(nike|air|kobe|lebron|dunk)/i },
+];
+
+/** The brand of a release on a brand's own calendar. */
+function brandFor(title, fallback = 'Nike') {
+ const t = String(title || '').trim();
+ for (const rule of BRAND_RULES) if (rule.test.test(t)) return rule.brand;
+ return fallback;
+}
+
+/** Undo the escaping the page puts its JSON payload through. */
+function decode(text) {
+ // The page puts its payload in a JavaScript string literal: unescape one level (structural quotes become
+ // JSON quotes, inner quotes stay escaped), then turn the page's escaped slashes/ampersands into characters.
+ const unescape = (value) => String(value || '').replace(/\\(u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.)/g, (m, c) => {
+ if (c[0] === 'u' || c[0] === 'x') {
+ const code = parseInt(c.slice(1), 16);
+ return Number.isFinite(code) ? String.fromCharCode(code) : c;
+ }
+ return ({ n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', '0': '\0' })[c] ?? c;
+ });
+ return unescape(text).replace(/\\u002F/g, '/').replace(/\\u0026/g, '&');
+}
+
+/** The size grid a release page published. */
+function sizesIn(window) {
+ const out = [];
+ const re = /"nike_size":"([^"]+)"/g;
+ let m;
+ while ((m = re.exec(window)) !== null) {
+ const size = parse.cleanSize(m[1]);
+ if (size && !out.includes(size)) out.push(size);
+ }
+ return out;
+}
+
+/** One field of the payload, as its own value. */
+function field(window, name) {
+ const m = window.match(new RegExp('"' + name + '":("(?:[^"\\\\]|\\\\.)*"|[0-9.]+|true|false|null)'));
+ if (!m) return null;
+ const raw = m[1];
+ if (raw.startsWith('"')) {
+ try { return JSON.parse(raw); } catch { return raw.slice(1, -1); }
+ }
+ if (raw === 'true') return true;
+ if (raw === 'false') return false;
+ if (raw === 'null') return null;
+ return Number(raw);
+}
+
+/**
+ * The releases a launch calendar page publishes. Each one: { brand, model, colourway, releaseAt, price, sizes,
+ * sourceKey }. A release without a date or without a model name is skipped: the page does not prove it.
+ */
+function parseLaunchCalendar(html, { sourceName = 'official release calendar', sourceUrl = '', fetchedAt = null } = {}) {
+ const text = decode(html);
+ const marks = [];
+ const re = /"commerceStartDate":"([^"]+)"/g;
+ let m;
+ while ((m = re.exec(text)) !== null) marks.push({ at: m.index, date: m[1] });
+ const out = [];
+ for (let i = 0; i < marks.length; i++) {
+ const start = marks[i].at;
+ const end = i + 1 < marks.length ? marks[i + 1].at : text.length;
+ const window = text.slice(start, end);
+ const releaseAt = new Date(marks[i].date);
+ if (Number.isNaN(releaseAt.getTime())) continue;
+ const title = parse.cleanName(field(window, 'title'), 120);
+ if (!title) continue;
+ const brand = brandFor(title);
+ const colourway = parse.colourwayOf(title);
+ const model = parse.modelOf(title, brand);
+ if (!model) continue;
+ const full = field(window, 'fullPrice');
+ const price = parse.amountOf(full != null ? full : field(window, 'msrp'));
+ const sizes = sizesIn(window);
+ const styleColor = field(window, 'styleColor') || '';
+ const sourceKey = [title, styleColor, marks[i].date].join('|');
+ out.push({
+ brand, model, colourway,
+ releaseAt: releaseAt.toISOString(),
+ price,
+ currency: 'USD',
+ sizes,
+ sourceKey,
+ sourceName,
+ sourceUrl,
+ fetchedAt: fetchedAt || new Date().toISOString(),
+ });
+ }
+ return out;
+}
+
+/** A plain RSS feed: { title, link, pubDate } per item. Used by the extra feeds a host may configure. */
+function parseRss(xml, { sourceName = 'official release feed', sourceUrl = '', fetchedAt = null } = {}) {
+ const out = [];
+ const items = String(xml || '').split(/<item\b/i).slice(1);
+ for (const item of items) {
+ const pick = (tag) => {
+ const m = item.match(new RegExp('<' + tag + '[^>]*>([\\s\\S]*?)</' + tag + '>', 'i'));
+ return m ? m[1].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').replace(/<[^>]+>/g, '').trim() : null;
+ };
+ const title = pick('title');
+ const link = pick('link');
+ const date = pick('pubDate') || pick('dc:date');
+ if (!title || !date) continue;
+ const when = new Date(date);
+ if (Number.isNaN(when.getTime())) continue;
+ const brand = parse.brandOf(title) || 'Unknown';
+ const model = parse.modelOf(title, brand);
+ if (!model) continue;
+ out.push({
+ brand, model,
+ colourway: parse.colourwayOf(title),
+ releaseAt: when.toISOString(),
+ price: null,
+ currency: 'USD',
+ sizes: [],
+ sourceKey: link || title,
+ sourceName,
+ sourceUrl: link || sourceUrl,
+ fetchedAt: fetchedAt || new Date().toISOString(),
+ });
+ }
+ return out;
+}
+
+module.exports = { parseLaunchCalendar, parseRss, brandFor, decode, sizesIn, field };
new file mode 100644
new file mode 100644
@@ -0,0 +1,227 @@
@@ -0,0 +1,227 @@
+'use strict';
+
+/**
+ * The rules that turn a source's words into Shoes' own fields. Pure: no clock, no database, no network. Every
+ * rule here is a promise the site makes, so each one is unit tested (test/parse.test.js):
+ *
+ * isShoe whether a listing is footwear at all (a keyword, or a model Shoes knows)
+ * brandOf which brand a title names (the source's own brand first, then a known list)
+ * modelOf the model name inside a title, with the brand and the "running shoe" words taken off
+ * colourwayOf a colourway the title quotes or the source names; null when it names none
+ * slugFor /shoe/<brand>-<model>
+ * sizeOf the size a title names ("Size 10.5", "US 9"); null when it names none
+ * conditionOf new | used | deadstock | null (not stated)
+ * amountOf a price as a number; null when it is not a price
+ * allIn price + shipping when the store states shipping, else the price alone
+ *
+ * Nothing here guesses: a field the source did not give stays null and the page says "not known yet".
+ */
+
+/** The brands Shoes recognises in a title. Longest first, so "New Balance" wins over "Balance". */
+const BRANDS = Object.freeze([
+ 'Adidas Originals', 'Air Jordan', 'Under Armour', 'New Balance', 'Dr. Martens', 'The North Face',
+ 'Nike', 'Jordan', 'Adidas', 'Puma', 'Reebok', 'ASICS', 'Asics', 'Converse', 'Vans', 'Crocs', 'Skechers',
+ 'Clarks', 'Merrell', 'Timberland', 'Steve Madden', 'Saucony', 'Brooks', 'Hoka', 'On', 'Fila', 'Mizuno',
+ 'Diadora', 'K-Swiss', 'Lotto', 'Champion', 'Guess', 'Diesel', 'Lacoste', 'Osiris', 'Etnies', 'Keen',
+ 'Wolverine', 'Reef', 'Olukai', 'UGG', 'Teva', 'Birkenstock', 'Bogs', 'Columbia', 'Hi-Tec', 'Keds',
+ 'Nautica', 'Rip Curl', 'Quiksilver', 'DC', 'Yeezy', 'Salomon', 'Arc', 'Terry', 'Zara', 'Geox',
+ 'Sperry', 'Cole Haan', 'ECCO', 'Dansko', 'Danner', 'Mephisto', 'Rieker', 'Josef Seibel', 'Naturalizer',
+ 'LifeStride', 'SoftWalk', 'Alegria', 'Xero Shoes', 'Altra', 'Topo Athletic', 'Inov-8', 'La Sportiva',
+]);
+
+const BRAND_ALIASES = Object.freeze({
+ 'air jordan': 'Jordan',
+ 'jordan brand': 'Jordan',
+ 'nike sb': 'Nike',
+ 'nike sportswear': 'Nike',
+ 'adidas originals': 'Adidas Originals',
+ 'new balance': 'New Balance',
+ 'asics': 'ASICS',
+ 'the north face': 'The North Face',
+ 'dr martens': 'Dr. Martens',
+ 'dr. martens': 'Dr. Martens',
+ 'under armour': 'Under Armour',
+ 'xero shoes': 'Xero Shoes',
+});
+
+/** The words that make a listing footwear. A title must name one of these, or a model below, to enter the catalogue. */
+const FOOTWEAR = new RegExp(`\\b(?:${[
+ 'shoe', 'shoes', 'sneaker', 'sneakers', 'trainer', 'trainers', 'boot', 'boots', 'sandal', 'sandals',
+ 'slide', 'slides', 'clog', 'clogs', 'loafer', 'loafers', 'heel', 'heels', 'pump', 'pumps', 'oxford',
+ 'oxfords', 'mule', 'mules', 'flip[- ]?flop', 'flip[- ]?flops', 'footwear', 'cleat', 'cleats', 'slipper',
+ 'slippers', 'espadrille', 'espadrilles', 'slingback', 'slingbacks', 'wedge', 'wedges', 'mary jane',
+].join('|')})\\b`, 'i');
+
+/** A model word that only a shoe has. "Air Force 1" is a shoe even when the title drops the word. */
+const SHOE_MODELS = new RegExp([
+ 'air max', 'air force', 'air jordan', 'jordan \\d', 'dunk', 'yeezy', 'ultraboost', 'ultra boost',
+ 'pegasus', 'gel[- ]kayano', 'gel[- ]nimbus', 'gel[- ]cumulus', 'samba', 'gazelle', 'stan smith',
+ 'superstar', 'chuck taylor', 'old skool', 'sk8', 'air monarch', 'air trainer', 'metcon', 'free run',
+ 'zoom fly', 'vaporfly', 'alphafly', '990', '991', '992', '993', '574', '327', '9060', '550',
+ 'cloudfoam', 'nmd', 'forum low', 'campus', 'sl 72', 'japan s', 'gazelle indoor', 'new balance 1906',
+ 'on cloud', 'cloudmonster', 'cloudrunner', 'hoka bondi', 'hoka clifton', 'salomon xt', 'salomon speedcross',
+ 'blundstone', 'chelsea boot', 'timberland 6', 'doc martens 1460', 'birkenstock boston', 'birkenstock arizona',
+].join('|'), 'i');
+
+/** Leading words a title uses for the person it is for, not for the model. */
+const PERSON_WORDS = /^(?:(?:men'?s|women'?s|kids'?|kid'?s|boys'?|girls'?|unisex|toddler|infant|baby|big kid|little kid|youth)\s+)+/i;
+
+/** Trailing words a title uses for the kind of thing it is, not for the model. */
+const TYPE_WORDS = /(?:\s*[-–—,:]\s*|\s+)(?:men'?s|women'?s|kids'?|toddler|wide|medium|regular|standard|running shoe|running shoes|sneaker|sneakers|shoe|shoes|trainer|trainers|boot|boots|sandal|sandals|slide|slides|loafer|loafers|slipper|slippers|clog|clogs)\s*$/i;
+
+const SIZE_RE = [
+ /\bsize\s*[:.]?\s*(\d{1,2}(?:\.\d)?)\b/i,
+ /\bus\s*(\d{1,2}(?:\.\d)?)\b/i,
+ /\b(\d{1,2}(?:\.\d)?)\s*(?:us|mens|mens'|womens|womens')\b/i,
+ /\b(\d{1,2}(?:\.\d)?)\s*(?:men|women|kids?)\b/i,
+];
+
+/** A clean name: spaces collapsed, no trailing punctuation, at most `max` characters. */
+function cleanName(value, max = 120) {
+ const s = String(value == null ? '' : value).replace(/\s+/g, ' ').trim().replace(/[\s.,;:]+$/, '');
+ return s.slice(0, max);
+}
+
+/** The slug of a model: /shoe/<brand>-<model>. Lower case, words joined by a dash. */
+function slugFor(brand, model) {
+ const parts = [brand, model]
+ .map((x) => String(x == null ? '' : x).toLowerCase())
+ .join(' ')
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '');
+ return parts.slice(0, 80).replace(/-$/, '');
+}
+
+/** The brand a title names: the source's own brand first (when it is a brand we know), else the title's. */
+function brandOf(title, brandHint) {
+ const hint = cleanName(brandHint, 80);
+ if (hint) {
+ const alias = BRAND_ALIASES[hint.toLowerCase()];
+ if (alias) return alias;
+ const known = BRANDS.find((b) => b.toLowerCase() === hint.toLowerCase());
+ return known || hint;
+ }
+ const t = String(title == null ? '' : title);
+ const low = t.toLowerCase();
+ for (const b of BRANDS) {
+ const key = b.toLowerCase();
+ if (low.startsWith(key + ' ') || low.startsWith(key + '-') || low.startsWith(key + ',')) return BRAND_ALIASES[key] || b;
+ }
+ for (const b of BRANDS) {
+ const key = b.toLowerCase();
+ if (low.includes(' ' + key + ' ')) return BRAND_ALIASES[key] || b;
+ }
+ return null;
+}
+
+/** Whether a listing is footwear: a footwear word, or a model only a shoe has. */
+function isShoe(...titles) {
+ const t = titles.filter(Boolean).join(' ');
+ return FOOTWEAR.test(t) || SHOE_MODELS.test(t);
+}
+
+/** A colourway named by the source, or quoted in the title ("Air Max 90 \"Medium Olive\""). */
+function colourwayOf(title, hint) {
+ const h = cleanName(hint, 80);
+ if (h) return h;
+ const quoted = String(title == null ? '' : title).match(/["“”]([^"“”]{2,60})["“”]/);
+ if (quoted) return cleanName(quoted[1], 60) || null;
+ return null;
+}
+
+/**
+ * The model name inside a title: the person words, the brand and the kind words taken off, a quoted colourway
+ * removed. "Women's Nike Air Max 90 Running Shoe" → "Air Max 90".
+ */
+function modelOf(title, brand) {
+ let t = cleanName(title, 200);
+ t = t.replace(PERSON_WORDS, '');
+ // A quoted colourway is the colourway, not the model.
+ t = t.replace(/\s*["“”][^"“”]{2,60}["“”]/g, '').trim();
+ const b = cleanName(brand, 80);
+ if (b && b.toLowerCase() === 'jordan') t = t.replace(/^air\s+/i, '').trim();
+ if (b) {
+ const key = b.toLowerCase();
+ const idx = t.toLowerCase().indexOf(key);
+ if (idx >= 0) {
+ // Remove the brand once, wherever it sits after the person words ("Air Jordan 14 Retro" → "14 Retro").
+ t = `${t.slice(0, idx)} ${t.slice(idx + key.length)}`.replace(/\s+/g, ' ').trim();
+ }
+ }
+ t = t.replace(/\s+[-–—]\s+.*$/, '').trim();
+ // A kind word in the middle is still the kind, not the model: "Samba OG Shoes Size 10.5".
+ t = t.replace(/\s+(?:running shoes?|sneakers?|shoes?|trainers?|boots?|sandals?|slides?|loafers?|slippers?|clogs?)\s+(?=size\b|us\b)/i, ' ').trim();
+ for (let i = 0; i < 3; i++) t = t.replace(TYPE_WORDS, '').trim();
+ return cleanName(t, 120);
+}
+
+/** The size a title names, as its own string ("10.5"); null when the title names none. */
+function sizeOf(title) {
+ const t = String(title == null ? '' : title);
+ for (const re of SIZE_RE) {
+ const m = t.match(re);
+ if (m) return cleanSize(m[1]);
+ }
+ return null;
+}
+
+/** A size as the source wrote it, normalised: "10.5", "10.5C", "5Y". */
+function cleanSize(value) {
+ const s = String(value == null ? '' : value).trim().toUpperCase();
+ const m = s.match(/^(\d{1,2}(?:\.\d)?)([A-Z]*)$/);
+ if (!m) return s ? s.slice(0, 12) : null;
+ return m[1] + m[2];
+}
+
+/** new | used | deadstock | null (the source did not say). */
+function conditionOf(value) {
+ const s = String(value == null ? '' : value).trim().toLowerCase();
+ if (!s) return null;
+ if (/^(new|brand new|bnwt|new with tags|new with box|new in box)/.test(s)) return 'new';
+ if (/^(used|pre-?owned|preowned|second hand)/.test(s)) return 'used';
+ if (/^(deadstock|ds|dead stock)/.test(s)) return 'deadstock';
+ return null;
+}
+
+/** A price as a number, or null when it is not one. "$1,299.00" → 1299. */
+function amountOf(value) {
+ if (value == null || value === '') return null;
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
+ const m = String(value).replace(/[^0-9.]/g, '');
+ if (!m) return null;
+ const n = Number.parseFloat(m);
+ return Number.isFinite(n) ? n : null;
+}
+
+/**
+ * The all-in price: the price plus the shipping when the store states it. The flag says whether the shipping
+ * was known, so a page never claims a shipping it did not see.
+ */
+function allIn(price, shipping) {
+ const p = amountOf(price);
+ const sh = amountOf(shipping);
+ if (p == null) return { amount: null, shippingKnown: false, shipping: null };
+ if (sh == null) return { amount: p, shippingKnown: false, shipping: null };
+ return { amount: Math.round((p + sh) * 100) / 100, shippingKnown: true, shipping: sh };
+}
+
+/** The day a check belongs to (UTC), the key of the price history. */
+function dayOf(when) {
+ const d = when instanceof Date ? when : new Date(when);
+ if (Number.isNaN(d.getTime())) return null;
+ return d.toISOString().slice(0, 10);
+}
+
+/** A price is stale when it was checked longer ago than the hours allowed. */
+function isStale(checkedAt, nowMs, staleHours) {
+ const t = checkedAt instanceof Date ? checkedAt.getTime() : Date.parse(checkedAt);
+ if (!Number.isFinite(t)) return false;
+ const hours = Number(staleHours) > 0 ? Number(staleHours) : 48;
+ return nowMs - t > hours * 3600_000;
+}
+
+module.exports = {
+ BRANDS, BRAND_ALIASES, SIZE_RE, FOOTWEAR, SHOE_MODELS,
+ cleanName, slugFor, brandOf, modelOf, colourwayOf, isShoe, sizeOf, cleanSize, conditionOf, amountOf, allIn, dayOf, isStale,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,190 @@
@@ -0,0 +1,190 @@
+'use strict';
+
+/**
+ * /api/v1 — OpenVibe.Shoes' JSON API.
+ *
+ * GET /shoes?q&brand&size&condition&store&sort&page&limit public search, with facets
+ * GET /shoes/:slug offers, own history, size curve, cited facts
+ * GET /shoes/:slug/price?size=&condition= the lowest all-in price (a Watch polls this)
+ * GET /drops?from&to official release dates, soonest first
+ * GET /drops/:id one release
+ * GET /me/watches the signed-in person's watches
+ * POST /me/watches a price watch through OpenVibe.Watch
+ * POST /me/track a pasted listing link through Watch's rules
+ * GET /status the last read of each source
+ * GET /openapi.json this API as OpenAPI 3.1
+ *
+ * Errors are application/problem+json with a `code`. A person is the shoes_at session cookie or a Network user
+ * token as a Bearer; a session write must come from open.shoes itself (403 shoes.cross_site).
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { asyncRouter } = require('./router');
+const { openapi } = require('./openapi');
+
+const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
+const SORTS = new Set(['recent', 'drop', 'price', 'price_desc', 'popular', 'name']);
+const CONDITIONS = new Set(['new', 'used', 'deadstock']);
+
+function createApi(ctx) {
+ const { config, catalogue, principal, upstreams } = ctx;
+ const r = asyncRouter();
+ r.use(express.json({ limit: '64kb' }));
+ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+ r.use(principal.middleware);
+
+ const fail = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
+ const str = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '');
+
+ r.get('/shoes', async (req, res) => {
+ const page = Math.max(1, Math.min(200, parseInt(req.query.page, 10) || 1));
+ const limit = Math.max(1, Math.min(100, parseInt(req.query.limit, 10) || 24));
+ const sort = SORTS.has(req.query.sort) ? req.query.sort : 'recent';
+ const filters = {
+ q: str(req.query.q, 80) || null,
+ brand: str(req.query.brand, 80) || null,
+ size: str(req.query.size, 12) || null,
+ condition: CONDITIONS.has(req.query.condition) ? req.query.condition : null,
+ store: str(req.query.store, 120) || null,
+ };
+ const [shoes, facets, total] = await Promise.all([
+ catalogue.listShoes({ ...filters, sort, limit, offset: (page - 1) * limit }),
+ catalogue.facets(),
+ catalogue.countShoes(filters),
+ ]);
+ res.set('Cache-Control', 'public, max-age=60');
+ return res.json({ shoes, facets, page, per_page: limit, total, sort, filters });
+ });
+
+ r.get('/shoes/:slug', async (req, res) => {
+ const slug = str(req.params.slug, 80);
+ if (!SLUG.test(slug)) return fail(res, req, 404, 'shoe.not_found', 'No such shoe.');
+ const model = await catalogue.modelBySlug(slug);
+ if (!model) return fail(res, req, 404, 'shoe.not_found', 'No such shoe.');
+ const days = Math.max(1, Math.min(365, parseInt(req.query.days, 10) || 90));
+ const [offers, history, curve, facts, drops] = await Promise.all([
+ catalogue.offersOf(model.id),
+ catalogue.history(model.id, { days }),
+ catalogue.curve(model.id),
+ catalogue.factsOf(model.id),
+ catalogue.dropsForModel(model),
+ ]);
+ const prices = offers.map((o) => o.all_in).filter((p) => p != null).sort((a, b) => a - b);
+ const shoe = catalogue.wireModel({
+ ...model,
+ price_min: prices[0] ?? null,
+ price_max: prices[prices.length - 1] ?? null,
+ offer_count: offers.length,
+ stale_count: offers.filter((o) => o.stale).length,
+ checked_at: offers.map((o) => o.checked_at).filter(Boolean).sort().pop() || null,
+ });
+ res.set('Cache-Control', 'public, max-age=60');
+ return res.json({ shoe, offers, history, curve, facts, drops });
+ });
+
+ r.get('/shoes/:slug/price', async (req, res) => {
+ const slug = str(req.params.slug, 80);
+ if (!SLUG.test(slug)) return fail(res, req, 404, 'shoe.not_found', 'No such shoe.');
+ const model = await catalogue.modelBySlug(slug);
+ if (!model) return fail(res, req, 404, 'shoe.not_found', 'No such shoe.');
+ const size = str(req.query.size, 12) || null;
+ const condition = CONDITIONS.has(req.query.condition) ? req.query.condition : null;
+ const out = await catalogue.priceFor(model.id, { size, condition });
+ res.set('Cache-Control', 'no-store');
+ return res.json({ slug, size, condition, price: out.price, currency: 'USD', checked_at: out.checked_at, offers: out.offers });
+ });
+
+ r.get('/drops', async (req, res) => {
+ const from = req.query.from ? new Date(String(req.query.from)) : new Date(Date.now() - 3600_000);
+ const to = req.query.to ? new Date(String(req.query.to)) : null;
+ if (Number.isNaN(from.getTime()) || (to && Number.isNaN(to.getTime()))) return fail(res, req, 400, 'request.invalid', 'from and to must be ISO times.');
+ if (to && from.getTime() > to.getTime()) return fail(res, req, 400, 'request.invalid', 'from must not be later than to.');
+ const drops = await catalogue.listDrops({ from: from.toISOString(), to: to ? to.toISOString() : null, limit: 200 });
+ res.set('Cache-Control', 'public, max-age=60');
+ return res.json({ from: from.toISOString(), to: to ? to.toISOString() : null, drops });
+ });
+
+ r.get('/drops/:id', async (req, res) => {
+ const drop = await catalogue.dropById(str(req.params.id, 40));
+ if (!drop) return fail(res, req, 404, 'drop.not_found', 'No such drop.');
+ return res.json(drop);
+ });
+
+ r.get('/me/watches', principal.requirePerson, async (req, res) => {
+ const watches = await catalogue.listWatches(req.principal.subject);
+ return res.json({ watches });
+ });
+
+ r.post('/me/watches', principal.requirePerson, async (req, res) => {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const slug = str(b.slug, 80);
+ const threshold = Number(b.threshold);
+ if (!SLUG.test(slug)) return fail(res, req, 400, 'watch.invalid', 'slug must be a shoe slug from /api/v1/shoes.');
+ if (!Number.isFinite(threshold) || threshold <= 0 || threshold > 1_000_000) return fail(res, req, 400, 'watch.invalid', 'threshold must be a positive amount.');
+ const size = str(b.size, 12) || null;
+ const condition = CONDITIONS.has(b.condition) ? b.condition : null;
+ const model = await catalogue.modelBySlug(slug);
+ if (!model) return fail(res, req, 404, 'shoe.not_found', 'No such shoe.');
+ const qs = new URLSearchParams({ size: size || '', condition: condition || '' });
+ const feedUrl = `${String(config.baseUrl).replace(/\/+$/, '')}/api/v1/shoes/${encodeURIComponent(slug)}/price?${qs.toString()}`;
+ const body = {
+ name: `Shoes: ${model.brand} ${model.model}${size ? ` size ${size}` : ''} below $${threshold}`.slice(0, 120),
+ source: { kind: 'api', url: feedUrl },
+ cadence: { every_sec: config.watch.everySec },
+ extraction: { kind: 'json', value_path: 'price' },
+ condition: { op: 'lt', value: threshold },
+ action: [{ kind: 'notification', category: 'service', priority: 'normal' }],
+ };
+ const out = await upstreams.createWatch(body, { subject: req.principal.subject });
+ const watch = await catalogue.createWatch({
+ subject: req.principal.subject, kind: 'price', modelId: model.id, slug, size, condition, threshold,
+ currency: 'USD', remoteId: out.ok && out.watch ? out.watch.id : null,
+ status: out.ok ? 'active' : 'not_connected', note: out.ok ? null : out.detail,
+ });
+ return res.status(201).json({ watch, remote: out.ok ? out.watch : null, note: out.ok ? null : out.detail });
+ });
+
+ r.post('/me/track', principal.requirePerson, async (req, res) => {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const checkedUrl = await upstreams.checkPastedUrl(b.url);
+ if (!checkedUrl.ok) return fail(res, req, 400, checkedUrl.code, checkedUrl.detail);
+ const body = {
+ name: `Shoes: a pasted listing (${checkedUrl.host})`.slice(0, 120),
+ source: { kind: 'http', url: checkedUrl.url, method: 'GET' },
+ cadence: { every_sec: config.watch.everySec },
+ extraction: { kind: 'text' },
+ condition: { op: 'changed' },
+ action: [{ kind: 'notification', category: 'service', priority: 'normal' }],
+ };
+ const out = await upstreams.createWatch(body, { subject: req.principal.subject });
+ const watch = await catalogue.createWatch({
+ subject: req.principal.subject, kind: 'link', sourceUrl: checkedUrl.url,
+ remoteId: out.ok && out.watch ? out.watch.id : null,
+ status: out.ok ? 'active' : 'not_connected', note: out.ok ? null : out.detail,
+ });
+ return res.status(201).json({ watch, remote: out.ok ? out.watch : null, note: out.ok ? null : out.detail });
+ });
+
+ r.get('/status', async (_req, res) => {
+ const [counts, jobs, facets] = await Promise.all([catalogue.counts(), catalogue.lastJobRuns(), catalogue.facets()]);
+ res.set('Cache-Control', 'public, max-age=30');
+ return res.json({
+ service: 'shoes', counts,
+ facets: { models: facets.models, offers: facets.offers, stores: facets.stores.length, drops: facets.drops, checked_at: facets.checked_at },
+ jobs,
+ sources: {
+ deals: config.sources.dealsUrl,
+ drops: config.sources.dropsUrl,
+ ebay: upstreams.ebayConfigured() ? 'configured' : 'off (no key pair)',
+ watch: config.watch.token ? 'configured' : 'off (no service token)',
+ },
+ });
+ });
+
+ r.get('/openapi.json', (req, res) => res.json(openapi(config)));
+
+ r.use((req, res) => fail(res, req, 404, 'route.not_found', 'No such API route.'));
+ return r;
+}
+
+module.exports = { createApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
+'use strict';
+
+/**
+ * Crawl artifacts for open.shoes: robots.txt, sitemap.xml, llms.txt and llms-full.txt. The model pages, the
+ * catalogue and the drops are for search engines and AI crawlers; sign-in, the API, account pages and the internal
+ * route are not. A sitemap lists every model Shoes knows (up to 100 in the artifact; the API pages the rest).
+ */
+const fs = require('fs');
+const path = require('path');
+const seo = require('openvibe-shared/seo');
+const cache = require('openvibe-shared/cache-policy');
+const { asyncRouter } = require('./router');
+
+const SITE_NAME = 'OpenVibe.Shoes';
+const HOST = 'open.shoes';
+const DESCRIPTION = 'OpenVibe.Shoes is every shoe, every market, the right price: one page per model with every store\'s all-in price, a price history from Shoes\' own daily snapshots, the size-price curve and a watch for your size.';
+const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics', '/watches', '/track'];
+
+function siteUpdated() {
+ try { return String(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated || '').slice(0, 10) || null; } catch { return null; }
+}
+
+function homeJsonLd(config) {
+ const site = String(config.baseUrl || `https://${HOST}`).replace(/\/+$/, '');
+ return [
+ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION, searchUrl: `${site}/shoes?q={q}` }),
+ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'ShoppingApplication', keywords: 'shoes, sneakers, all-in price, price history, size price, drops calendar, price watch, open data' }),
+ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
+ ];
+}
+
+function productJsonLd(config, model, offers, low) {
+ const site = String(config.baseUrl || `https://${HOST}`).replace(/\/+$/, '');
+ const url = `${site}/shoe/${model.slug}`;
+ const prices = offers.map((o) => o.all_in).filter((p) => p != null);
+ const out = seo.jsonLd.product({
+ name: `${model.brand} ${model.model}`,
+ url,
+ description: `${model.brand} ${model.model}: every store's all-in price, the price history and the size-price curve.`,
+ brand: model.brand,
+ offers: prices.length ? {
+ '@type': 'AggregateOffer',
+ priceCurrency: 'USD',
+ lowPrice: Math.min(...prices),
+ highPrice: Math.max(...prices),
+ offerCount: offers.length,
+ availability: 'https://schema.org/InStock',
+ url,
+ } : undefined,
+ });
+ out['@context'] = 'https://schema.org';
+ return out;
+}
+
+function publicPages(models = []) {
+ const pages = [
+ { path: '/', changefreq: 'hourly', priority: 1.0, title: 'OpenVibe.Shoes home', text: 'Every shoe, every market, the right price: all-in prices, the price history and the size-price curve.' },
+ { path: '/shoes', changefreq: 'hourly', priority: 0.9, title: 'Every shoe', text: 'Search every model by brand, size and condition; every offer is all-in with its store and the time it was checked.' },
+ { path: '/drops', changefreq: 'daily', priority: 0.8, title: 'The drops calendar', text: 'Official shoe release dates, each cited to the brand\'s or store\'s own release page, with a reminder on OpenVibe.Today.' },
+ { path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The Shoes API', text: 'The open JSON API for shoes, offers, history, curves, drops and watches, and the OpenAPI document.' },
+ ];
+ for (const m of models) {
+ pages.push({
+ path: `/shoe/${m.slug}`, changefreq: 'daily', priority: 0.8,
+ title: `${m.brand} ${m.model}`, text: `${m.brand} ${m.model}: ${m.offer_count} offer${m.offer_count === 1 ? '' : 's'}${m.price_min != null ? `, from $${Number(m.price_min).toFixed(2)} all-in` : ''}.`,
+ });
+ }
+ return pages;
+}
+
+function createDiscoveryRoutes(ctx) {
+ const { config, catalogue } = ctx;
+ const r = asyncRouter();
+ const base = () => String(config.baseUrl || `https://${HOST}`).replace(/\/+$/, '');
+
+ r.get('/robots.txt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }));
+ res.send(seo.robotsTxt({ sitemaps: [`${base()}/sitemap.xml`], disallow: DISALLOW }));
+ });
+
+ r.get('/sitemap.xml', async (_req, res) => {
+ const models = await catalogue.listShoes({ sort: 'recent', limit: 100 });
+ const urls = publicPages(models).map((p) => ({ loc: `${base()}${p.path}`, lastmod: siteUpdated() || undefined, changefreq: p.changefreq, priority: p.priority }));
+ res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 600 }));
+ res.send(seo.sitemapXml(urls));
+ });
+
+ r.get('/llms.txt', (_req, res) => {
+ const pages = publicPages([]);
+ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 600 }));
+ res.send(seo.llmsTxt({
+ name: SITE_NAME,
+ summary: DESCRIPTION,
+ details: 'Public model pages, a public JSON API and an OpenAPI document. No sign-in is needed for the public reads; a price watch and a pasted listing link need a person.',
+ sections: [
+ { title: 'Pages', links: pages.map((p) => ({ title: p.title, url: `${base()}${p.path}`, note: p.text })) },
+ { title: 'API', links: [
+ { title: 'Search shoes', url: `${base()}/api/v1/shoes?q=air+max` },
+ { title: 'One shoe', url: `${base()}/api/v1/shoes/nike-air-max-90` },
+ { title: 'Drops', url: `${base()}/api/v1/drops` },
+ { title: 'OpenAPI', url: `${base()}/api/openapi.json` },
+ ] },
+ ],
+ }));
+ });
+
+ r.get('/llms-full.txt', async (_req, res) => {
+ const models = await catalogue.listShoes({ sort: 'recent', limit: 30 });
+ const pages = publicPages(models);
+ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 600 }));
+ res.send(seo.llmsFull({
+ site: SITE_NAME,
+ summary: DESCRIPTION,
+ base: base(),
+ sections: [{ title: 'Pages', pages: pages.map((p) => ({ title: p.title, url: `${base()}${p.path}`, text: p.text })) }],
+ }));
+ });
+
+ return r;
+}
+
+module.exports = { createDiscoveryRoutes, homeJsonLd, productJsonLd, publicPages, SITE_NAME, HOST, DESCRIPTION };
new file mode 100644
new file mode 100644
@@ -0,0 +1,176 @@
@@ -0,0 +1,176 @@
+'use strict';
+
+/**
+ * The OpenAPI 3.1 document for the Shoes API. Kept as data, not a framework: every route the service answers is
+ * listed here with its parameters and an honest answer shape. The servers are this host and the production host.
+ */
+function openapi(config = {}) {
+ const base = String(config.baseUrl || 'https://open.shoes').replace(/\/+$/, '');
+ const problem = { $ref: '#/components/schemas/Problem' };
+ const params = {
+ q: { name: 'q', in: 'query', schema: { type: 'string', maxLength: 80 }, description: 'A brand, model or name fragment.' },
+ brand: { name: 'brand', in: 'query', schema: { type: 'string', maxLength: 80 } },
+ size: { name: 'size', in: 'query', schema: { type: 'string', maxLength: 12 } },
+ condition: { name: 'condition', in: 'query', schema: { type: 'string', enum: ['new', 'used', 'deadstock'] } },
+ store: { name: 'store', in: 'query', schema: { type: 'string', maxLength: 120 } },
+ sort: { name: 'sort', in: 'query', schema: { type: 'string', enum: ['recent', 'drop', 'price', 'price_desc', 'popular', 'name'], default: 'recent' } },
+ page: { name: 'page', in: 'query', schema: { type: 'integer', minimum: 1, default: 1 } },
+ limit: { name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 100, default: 24 } },
+ slug: { name: 'slug', in: 'path', required: true, schema: { type: 'string', maxLength: 80 } },
+ from: { name: 'from', in: 'query', schema: { type: 'string', format: 'date-time' } },
+ to: { name: 'to', in: 'query', schema: { type: 'string', format: 'date-time' } },
+ };
+ const ok = (schema) => ({ 200: { description: 'OK', content: { 'application/json': { schema } } } });
+ const bad = { 400: { description: 'Bad request', content: { 'application/problem+json': { schema: problem } } }, 404: { description: 'Not found', content: { 'application/problem+json': { schema: problem } } } };
+ return {
+ openapi: '3.1.0',
+ info: {
+ title: 'OpenVibe.Shoes API',
+ version: '0.1.0',
+ summary: 'Every shoe, every market, the right price.',
+ description: 'Shoes aggregates official store offers into one all-in price per variant, keeps its own daily price snapshots, and exposes the drops calendar and a person\'s watches. Public reads take no key. There are no affiliate links and no Marketplace scraping.',
+ license: { name: 'AGPL-3.0-only', url: 'https://www.gnu.org/licenses/agpl-3.0.html' },
+ },
+ servers: [{ url: base }, { url: 'https://open.shoes' }],
+ tags: [{ name: 'shoes' }, { name: 'drops' }, { name: 'me' }, { name: 'status' }],
+ paths: {
+ '/api/v1/shoes': {
+ get: {
+ tags: ['shoes'], summary: 'Search the catalogue', operationId: 'listShoes',
+ parameters: [params.q, params.brand, params.size, params.condition, params.store, params.sort, params.page, params.limit],
+ responses: { ...ok({ $ref: '#/components/schemas/ShoeList' }), ...bad },
+ },
+ },
+ '/api/v1/shoes/{slug}': {
+ get: {
+ tags: ['shoes'], summary: 'One model with its offers, history, curve and cited facts', operationId: 'getShoe',
+ parameters: [params.slug],
+ responses: { ...ok({ $ref: '#/components/schemas/ShoePage' }), ...bad },
+ },
+ },
+ '/api/v1/shoes/{slug}/price': {
+ get: {
+ tags: ['shoes'], summary: 'The lowest all-in price for a model (and size)', operationId: 'getShoePrice',
+ parameters: [params.slug, params.size, params.condition],
+ responses: { ...ok({ $ref: '#/components/schemas/Price' }), ...bad },
+ },
+ },
+ '/api/v1/drops': {
+ get: {
+ tags: ['drops'], summary: 'The official release dates', operationId: 'listDrops',
+ parameters: [params.from, params.to],
+ responses: { ...ok({ type: 'object', properties: { drops: { type: 'array', items: { $ref: '#/components/schemas/Drop' } } } }), ...bad },
+ },
+ },
+ '/api/v1/drops/{id}': {
+ get: {
+ tags: ['drops'], summary: 'One drop', operationId: 'getDrop',
+ parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
+ responses: { ...ok({ $ref: '#/components/schemas/Drop' }), ...bad },
+ },
+ },
+ '/api/v1/me/watches': {
+ get: {
+ tags: ['me'], summary: 'The signed-in person\'s watches', operationId: 'listWatches',
+ security: [{ bearerAuth: [] }, { cookieAuth: [] }],
+ responses: { ...ok({ type: 'object', properties: { watches: { type: 'array', items: { $ref: '#/components/schemas/Watch' } } } }), 401: { description: 'Not signed in', content: { 'application/problem+json': { schema: problem } } } },
+ },
+ post: {
+ tags: ['me'], summary: 'Tell me when this drops below $X at my size', operationId: 'createWatch',
+ security: [{ bearerAuth: [] }, { cookieAuth: [] }],
+ requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/WatchRequest' } } } },
+ responses: { 201: { description: 'Created', content: { 'application/json': { schema: { type: 'object', properties: { watch: { $ref: '#/components/schemas/Watch' }, remote: { type: 'object', nullable: true } } } } } }, ...bad },
+ },
+ },
+ '/api/v1/me/track': {
+ post: {
+ tags: ['me'], summary: 'Track a pasted listing link through OpenVibe.Watch', operationId: 'trackLink',
+ security: [{ bearerAuth: [] }, { cookieAuth: [] }],
+ requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['url'], properties: { url: { type: 'string', format: 'uri', maxLength: 2000 } } } } } },
+ responses: { 201: { description: 'Created', content: { 'application/json': { schema: { type: 'object', properties: { watch: { $ref: '#/components/schemas/Watch' }, remote: { type: 'object', nullable: true } } } } } }, ...bad },
+ },
+ },
+ '/api/v1/status': {
+ get: {
+ tags: ['status'], summary: 'The last read of each source', operationId: 'getStatus',
+ responses: { ...ok({ type: 'object', properties: { counts: { type: 'object' }, jobs: { type: 'array', items: { type: 'object' } } } }) },
+ },
+ },
+ },
+ components: {
+ securitySchemes: {
+ bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
+ cookieAuth: { type: 'apiKey', in: 'cookie', name: 'shoes_at' },
+ },
+ schemas: {
+ Problem: { type: 'object', required: ['type', 'title', 'status', 'code'], properties: { type: { type: 'string' }, title: { type: 'string' }, status: { type: 'integer' }, code: { type: 'string' }, detail: { type: 'string' } } },
+ Offer: {
+ type: 'object',
+ properties: {
+ id: { type: 'string' }, store: { type: 'string' }, store_name: { type: 'string', nullable: true },
+ title: { type: 'string' }, url: { type: 'string', format: 'uri' },
+ price: { type: 'number', nullable: true }, list_price: { type: 'number', nullable: true },
+ shipping: { type: 'number', nullable: true }, shipping_known: { type: 'boolean' }, all_in: { type: 'number', nullable: true },
+ drop_from_list: { type: 'number', nullable: true }, currency: { type: 'string' },
+ condition: { type: 'string', nullable: true }, size: { type: 'string', nullable: true }, colourway: { type: 'string', nullable: true },
+ checked_at: { type: 'string', format: 'date-time' }, stale: { type: 'boolean' }, source: { type: 'string' },
+ },
+ },
+ Shoe: {
+ type: 'object',
+ properties: {
+ id: { type: 'string' }, slug: { type: 'string' }, brand: { type: 'string' }, model: { type: 'string' }, name: { type: 'string' },
+ price_min: { type: 'number', nullable: true }, price_max: { type: 'number', nullable: true },
+ offer_count: { type: 'integer' }, store_count: { type: 'integer' }, stale_count: { type: 'integer' },
+ checked_at: { type: 'string', format: 'date-time', nullable: true }, drop_amount: { type: 'number', nullable: true }, drop_percent: { type: 'number', nullable: true },
+ },
+ },
+ ShoeList: {
+ type: 'object',
+ properties: {
+ shoes: { type: 'array', items: { $ref: '#/components/schemas/Shoe' } },
+ page: { type: 'integer' }, per_page: { type: 'integer' }, total: { type: 'integer' },
+ facets: { type: 'object' },
+ },
+ },
+ ShoePage: {
+ type: 'object',
+ properties: {
+ shoe: { $ref: '#/components/schemas/Shoe' },
+ offers: { type: 'array', items: { $ref: '#/components/schemas/Offer' } },
+ history: { type: 'array', items: { type: 'object', properties: { day: { type: 'string', format: 'date' }, price: { type: 'number' }, offers: { type: 'integer' } } } },
+ curve: { type: 'object', properties: { median: { type: 'number', nullable: true }, points: { type: 'array', items: { type: 'object' } } } },
+ facts: { type: 'array', items: { type: 'object', properties: { kind: { type: 'string' }, label: { type: 'string' }, value: { type: 'string' }, source_name: { type: 'string' }, source_url: { type: 'string' }, fetched_at: { type: 'string' } } } },
+ drops: { type: 'array', items: { $ref: '#/components/schemas/Drop' } },
+ },
+ },
+ Price: { type: 'object', properties: { slug: { type: 'string' }, size: { type: 'string', nullable: true }, condition: { type: 'string', nullable: true }, price: { type: 'number', nullable: true }, currency: { type: 'string' }, checked_at: { type: 'string', nullable: true }, offers: { type: 'integer' } } },
+ Drop: {
+ type: 'object',
+ properties: {
+ id: { type: 'string' }, slug: { type: 'string' }, brand: { type: 'string' }, model: { type: 'string' }, colourway: { type: 'string', nullable: true },
+ release_at: { type: 'string', format: 'date-time' }, price: { type: 'number', nullable: true }, currency: { type: 'string' },
+ sizes: { type: 'array', items: { type: 'string' } }, source_name: { type: 'string' }, source_url: { type: 'string', format: 'uri' }, fetched_at: { type: 'string' },
+ },
+ },
+ Watch: {
+ type: 'object',
+ properties: {
+ id: { type: 'string' }, kind: { type: 'string', enum: ['price', 'link'] }, slug: { type: 'string', nullable: true }, size: { type: 'string', nullable: true },
+ condition: { type: 'string', nullable: true }, threshold: { type: 'number', nullable: true }, currency: { type: 'string' },
+ source_url: { type: 'string', nullable: true }, remote_id: { type: 'string', nullable: true }, status: { type: 'string' }, note: { type: 'string', nullable: true }, created_at: { type: 'string' },
+ },
+ },
+ WatchRequest: {
+ type: 'object', required: ['slug', 'threshold'],
+ properties: {
+ slug: { type: 'string', maxLength: 80 }, size: { type: 'string', maxLength: 12 }, condition: { type: 'string', enum: ['new', 'used', 'deadstock'] },
+ threshold: { type: 'number', exclusiveMinimum: 0 }, currency: { type: 'string', default: 'USD' },
+ },
+ },
+ },
+ },
+ };
+}
+
+module.exports = { openapi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,233 @@
@@ -0,0 +1,233 @@
+'use strict';
+
+/**
+ * Shoes' server-rendered pages. They work without JavaScript: forms are plain POSTs and every link is a real link.
+ * Public pages are served public; a page drawn for a signed-in person is private, no-store.
+ *
+ * / the room's front door (sc.room: hero, proof, rails, scenes, explore, dev, faq)
+ * /shoes search and facets
+ * /shoe/:slug one model: offers, own snapshots, size curve, cited facts, the watch form
+ * /shoe/:slug/watch POST: a price watch through OpenVibe.Watch
+ * /track a pasted listing link, through Watch's own fetch rules
+ * /drops the official release calendar, with "remind me" on Today
+ * /drops/:id/remind POST: put the release on the person's Today agenda
+ * /watches a signed-in person's watches
+ * /developers the API
+ * /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt
+ */
+const express = require('express');
+const { asyncRouter } = require('./router');
+const { sameOrigin } = require('./principal');
+const { send } = require('../render/layout');
+const views = require('../views');
+const discovery = require('./discovery');
+
+const SORTS = new Set(['recent', 'drop', 'price', 'price_desc', 'popular', 'name']);
+const PER_PAGE = 24;
+
+function createPageRoutes(ctx) {
+ const { config, catalogue, upstreams } = ctx;
+ const r = asyncRouter();
+ r.use(express.urlencoded({ extended: false, limit: '64kb' }));
+ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
+ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
+ const base = () => String(config.baseUrl).replace(/\/+$/, '');
+ const noticeOf = (req) => (typeof req.query.notice === 'string' ? req.query.notice.slice(0, 240) : null);
+ const guardOrigin = (req, res) => {
+ if (req.viewer && req.viewer.kind === 'user' && req.viewer.token && !sameOrigin(req, base())) {
+ page(req, res, { title: 'Not from this site', body: views.signIn({ title: 'That came from somewhere else', next: '/' }), active: '/' }, 403);
+ return false;
+ }
+ return true;
+ };
+
+ r.get('/', async (req, res) => {
+ const now = Date.now();
+ const [facets, dropping, popular, jobs] = await Promise.all([
+ catalogue.facets(),
+ catalogue.listDrops({ from: new Date(now).toISOString(), to: new Date(now + 7 * 86400_000).toISOString(), limit: 12 }),
+ catalogue.listShoes({ sort: 'popular', limit: 8 }),
+ catalogue.lastJobRuns(),
+ ]);
+ const dropsToday = await catalogue.listShoes({ sort: 'drop', limit: 12 });
+ let curveModel = null; let curve = null;
+ for (const m of popular.slice(0, 6)) {
+ const c = await catalogue.curve(m.id);
+ if (c.points.length) { curveModel = m; curve = c; break; }
+ }
+ const sceneDrop = dropping[0] || (await catalogue.listDrops({ from: new Date(now).toISOString(), limit: 1 }))[0] || null;
+ const body = views.home({ facets, dropping, dropsToday, popular, curveModel, curve, sceneDrop, jobs, now });
+ return page(req, res, {
+ title: null,
+ description: 'OpenVibe.Shoes: every shoe, every market, the right price. A page per model with every store\'s all-in price, Shoes\' own price history, the size-price curve and a watch for your size.',
+ body,
+ active: '/',
+ index: true,
+ jsonLd: discovery.homeJsonLd(config),
+ });
+ });
+
+ r.get('/shoes', async (req, res) => {
+ const q = typeof req.query.q === 'string' ? req.query.q.trim().slice(0, 80) : '';
+ const brand = typeof req.query.brand === 'string' ? req.query.brand.trim().slice(0, 80) : '';
+ const size = typeof req.query.size === 'string' ? req.query.size.trim().slice(0, 12) : '';
+ const condition = ['new', 'used', 'deadstock'].includes(req.query.condition) ? req.query.condition : '';
+ const store = typeof req.query.store === 'string' ? req.query.store.trim().slice(0, 120) : '';
+ const sort = SORTS.has(req.query.sort) ? req.query.sort : 'recent';
+ const pageNo = Math.max(1, Math.min(200, parseInt(req.query.page, 10) || 1));
+ const filters = { q: q || null, brand: brand || null, size: size || null, condition: condition || null, store: store || null };
+ const [models, facets, total] = await Promise.all([
+ catalogue.listShoes({ ...filters, sort, limit: PER_PAGE, offset: (pageNo - 1) * PER_PAGE }),
+ catalogue.facets(),
+ catalogue.countShoes(filters),
+ ]);
+ return page(req, res, {
+ title: q ? `Shoes matching “${q}”` : 'Every shoe',
+ description: 'Search every shoe in OpenVibe.Shoes by brand, model, size and condition; every price is all-in with its store and the time it was checked.',
+ body: views.shoesIndex({ models, facets, query: { ...filters, sort }, page: pageNo, total }),
+ active: '/shoes',
+ robots: q || brand || size || condition || store ? 'noindex, follow' : 'index, follow',
+ });
+ });
+
+ r.get('/shoe/:slug', async (req, res) => {
+ const model = await catalogue.modelBySlug(String(req.params.slug || '').slice(0, 80));
+ if (!model) return page(req, res, { title: 'Not found', body: views.notFound(), active: '/shoes' }, 404);
+ const [offers, history, curve, facts, releases] = await Promise.all([
+ catalogue.offersOf(model.id),
+ catalogue.history(model.id, { days: 90 }),
+ catalogue.curve(model.id),
+ catalogue.factsOf(model.id),
+ catalogue.dropsForModel(model),
+ ]);
+ const low = offers.map((o) => o.all_in).filter((p) => p != null).sort((a, b) => a - b)[0];
+ return page(req, res, {
+ title: `${model.brand} ${model.model}${low != null ? `: ${views.money(low)} all-in` : ''}`,
+ description: `${model.brand} ${model.model}: every store's all-in price, Shoes' own price history, the size-price curve${facts.length ? ' and cited specs' : ''}.`,
+ body: views.shoe({ model, offers, history, curve, facts, drops: releases, signedIn: Boolean(signedIn(req)) }),
+ active: '/shoes',
+ index: true,
+ jsonLd: [discovery.productJsonLd(config, model, offers, low)],
+ });
+ });
+
+ r.post('/shoe/:slug/watch', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(302, `/auth/login?next=${encodeURIComponent(`/shoe/${req.params.slug}`)}`);
+ if (!guardOrigin(req, res)) return;
+ const model = await catalogue.modelBySlug(String(req.params.slug || '').slice(0, 80));
+ if (!model) return page(req, res, { title: 'Not found', body: views.notFound(), active: '/shoes' }, 404);
+ const threshold = Number(req.body.threshold);
+ if (!Number.isFinite(threshold) || threshold <= 0) {
+ const [offers, history, curve, facts, releases] = await Promise.all([catalogue.offersOf(model.id), catalogue.history(model.id), catalogue.curve(model.id), catalogue.factsOf(model.id), catalogue.dropsForModel(model)]);
+ return page(req, res, { title: 'A price is needed', body: views.shoe({ model, offers, history, curve, facts, drops: releases, signedIn: true }), active: '/shoes' }, 400);
+ }
+ const size = String(req.body.size || '').slice(0, 12) || null;
+ const condition = ['new', 'used', 'deadstock'].includes(req.body.condition) ? req.body.condition : null;
+ const qs = new URLSearchParams({ size: size || '', condition: condition || '' });
+ const feedUrl = `${base()}/api/v1/shoes/${encodeURIComponent(model.slug)}/price?${qs.toString()}`;
+ const body = {
+ name: `Shoes: ${model.brand} ${model.model}${size ? ` size ${size}` : ''} below $${threshold}`.slice(0, 120),
+ source: { kind: 'api', url: feedUrl },
+ cadence: { every_sec: config.watch.everySec },
+ extraction: { kind: 'json', value_path: 'price' },
+ condition: { op: 'lt', value: threshold },
+ action: [{ kind: 'notification', category: 'service', priority: 'normal' }],
+ };
+ const out = await upstreams.createWatch(body, { subject: req.viewer.subject });
+ await catalogue.createWatch({
+ subject: req.viewer.subject, kind: 'price', modelId: model.id, slug: model.slug, size, condition,
+ threshold, currency: 'USD', remoteId: out.ok && out.watch ? out.watch.id : null,
+ status: out.ok ? 'active' : 'not_connected', note: out.ok ? null : out.detail,
+ });
+ const notice = out.ok ? 'Your watch is in OpenVibe.Watch. It will tell you when the all-in price drops below your number.' : `Saved on Shoes, but not connected to OpenVibe.Watch yet: ${out.detail}`;
+ return res.redirect(302, `/watches?notice=${encodeURIComponent(notice)}`);
+ });
+
+ r.get('/track', (req, res) => page(req, res, {
+ title: 'Track a listing link',
+ description: 'Paste a store, eBay or Marketplace listing and OpenVibe.Watch keeps it, through its own fetch rules.',
+ body: views.track({ signedIn: Boolean(signedIn(req)), notice: noticeOf(req) }),
+ active: '/watches',
+ robots: 'noindex, follow',
+ }));
+
+ r.post('/track', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(302, '/auth/login?next=%2Ftrack');
+ if (!guardOrigin(req, res)) return;
+ const checkedUrl = await upstreams.checkPastedUrl(req.body.url);
+ if (!checkedUrl.ok) return page(req, res, { title: 'Track a listing link', body: views.track({ signedIn: true, notice: checkedUrl.detail, url: req.body.url }), active: '/watches', robots: 'noindex, follow' }, 400);
+ const body = {
+ name: `Shoes: a pasted listing (${checkedUrl.host})`.slice(0, 120),
+ source: { kind: 'http', url: checkedUrl.url, method: 'GET' },
+ cadence: { every_sec: config.watch.everySec },
+ extraction: { kind: 'text' },
+ condition: { op: 'changed' },
+ action: [{ kind: 'notification', category: 'service', priority: 'normal' }],
+ };
+ const out = await upstreams.createWatch(body, { subject: req.viewer.subject });
+ await catalogue.createWatch({
+ subject: req.viewer.subject, kind: 'link', sourceUrl: checkedUrl.url,
+ remoteId: out.ok && out.watch ? out.watch.id : null,
+ status: out.ok ? 'active' : 'not_connected', note: out.ok ? null : out.detail,
+ });
+ const notice = out.ok ? 'The listing is a Watch in OpenVibe.Watch. It applies its own fetch rules to every check.' : `Saved on Shoes, but not connected to OpenVibe.Watch yet: ${out.detail}`;
+ return res.redirect(302, `/watches?notice=${encodeURIComponent(notice)}`);
+ });
+
+ r.get('/drops', async (req, res) => {
+ const now = Date.now();
+ const list = await catalogue.listDrops({ from: new Date(now - 3600_000).toISOString(), limit: 120 });
+ return page(req, res, {
+ title: 'The drops calendar',
+ description: 'Official shoe release dates, each cited to the brand\'s or store\'s own public release page, with a reminder on OpenVibe.Today. Raffle entries are never automated.',
+ body: views.drops({ drops: list, signedIn: Boolean(signedIn(req)), notice: noticeOf(req) }),
+ active: '/drops',
+ index: true,
+ });
+ });
+
+ r.post('/drops/:id/remind', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(302, `/auth/login?next=${encodeURIComponent('/drops')}`);
+ if (!guardOrigin(req, res)) return;
+ const drop = await catalogue.dropById(String(req.params.id || ''));
+ if (!drop) return page(req, res, { title: 'Not found', body: views.notFound(), active: '/drops' }, 404);
+ if (Date.parse(drop.release_at) < Date.now() - 5 * 60_000) {
+ return res.redirect(302, `/drops?notice=${encodeURIComponent('That release has already happened, so there is nothing to remind you about.')}`);
+ }
+ const event = {
+ kind: 'event',
+ title: `Drop: ${drop.brand} ${drop.model}`.slice(0, 120),
+ description: `${drop.colourway ? `${drop.colourway}. ` : ''}Official release: ${drop.source_url}${drop.price != null ? ` · ${drop.price} ${drop.currency}` : ''}. Listed by ${drop.source_name}; read ${drop.fetched_at}. Shoes never enters raffles.`.slice(0, 1000),
+ starts_at: drop.release_at,
+ tz: 'UTC',
+ visibility: 'public',
+ };
+ const out = await upstreams.createTodayEvent(event, { token: req.viewer.token });
+ const notice = out.ok ? 'The drop is on your OpenVibe.Today agenda. Today carries the reminder; Shoes never enters the raffle.' : `Could not add it to Today: ${out.detail}`;
+ return res.redirect(302, `/drops?notice=${encodeURIComponent(notice)}`);
+ });
+
+ r.get('/watches', async (req, res) => {
+ const list = signedIn(req) ? await catalogue.listWatches(req.viewer.subject) : [];
+ return page(req, res, {
+ title: 'Your watches',
+ description: 'The price watches and pasted listing links that OpenVibe.Watch carries for you.',
+ body: views.watches({ watches: list, signedIn: Boolean(signedIn(req)), notice: noticeOf(req) }),
+ active: '/watches',
+ robots: 'noindex, nofollow',
+ });
+ });
+
+ r.get('/developers', (req, res) => page(req, res, {
+ title: 'The Shoes API',
+ description: 'The open JSON API for shoes, offers, price history, size curves, drops and watches, with an OpenAPI document.',
+ body: views.developers({ baseUrl: base() }),
+ active: '/developers',
+ index: true,
+ }));
+
+ r.use(discovery.createDiscoveryRoutes(ctx));
+ return r;
+}
+
+module.exports = { createPageRoutes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+'use strict';
+
+/**
+ * Who is calling /api/v1 — req.principal:
+ *
+ * { kind: 'user', subject: 'usr_…', username, role, token, viaSession }
+ * a person: their Network user token as a Bearer, or this site's session cookie (shoes_at).
+ * { kind: 'app', requester: 'app:…' | 'agent:…' | 'service:…', project, claims }
+ * a Network app, agent or service token for audience open.shoes. No Shoes route needs one; the shape is
+ * kept so a future capability route can be added in one place.
+ * { kind: 'anonymous' }
+ *
+ * A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the
+ * session or to anonymous. A write made with the session cookie must come from this site (Sec-Fetch-Site
+ * same-origin, or an Origin equal to ours): another site cannot make a signed-in visitor vote, caption or report.
+ */
+const { serviceAuth, http, ids } = require('openvibe-contracts');
+
+const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+// Shoes' routes are a person acting for themself and a public read. No capability.
+const CAPABILITIES = [];
+
+function decodePayload(token) {
+ const parts = String(token || '').split('.');
+ if (parts.length !== 3) return null;
+ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }
+}
+
+function roleClaim(claims) {
+ return typeof claims.role === 'string' && claims.role ? claims.role.slice(0, 32) : 'user';
+}
+function usernameClaim(claims) {
+ return typeof claims.username === 'string' ? claims.username.slice(0, 64) : null;
+}
+
+function requesterOfSub(sub) {
+ if (sub.startsWith('svc:')) return `service:${sub.slice(4)}`;
+ return sub;
+}
+
+/** Same-origin for a signed-in write: the browser's fetch metadata, else the Origin header. */
+function sameOrigin(req, baseUrl) {
+ const site = String(req.get('sec-fetch-site') || '');
+ if (site) return site === 'same-origin';
+ const origin = req.get('origin');
+ if (origin) return origin === baseUrl || origin === `${req.protocol}://${req.get('host')}`;
+ return false;
+}
+
+function createPrincipal({ config, keys }) {
+ async function resolve(req) {
+ const header = String(req.headers.authorization || '');
+ if (header.startsWith('Bearer ')) {
+ const token = header.slice(7).trim();
+ const payload = decodePayload(token);
+ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
+ const publicKey = await keys.pemForToken(token);
+ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
+ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });
+ if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };
+ const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;
+ return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, claims: r.claims } };
+ }
+ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience });
+ if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };
+ if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };
+ if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
+ return { principal: { kind: 'user', subject: v.claims.subject_id, requester: `user:${v.claims.subject_id}`, project: null, role: roleClaim(v.claims), username: usernameClaim(v.claims), token, viaSession: false } };
+ }
+ const viewer = req.viewer;
+ if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {
+ return { principal: { kind: 'user', subject: viewer.subject, requester: `user:${viewer.subject}`, project: null, role: viewer.role || 'user', username: viewer.username || null, token: viewer.token, viaSession: true } };
+ }
+ return { principal: { kind: 'anonymous' } };
+ }
+
+ async function middleware(req, res, next) {
+ const r = await resolve(req);
+ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });
+ req.principal = r.principal;
+ return next();
+ }
+
+ function require_(cap) {
+ if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.Shoes's capabilities`);
+ return function guard(req, res, next) {
+ if (req.principal.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at open.shoes, or send a person\'s Network token as a Bearer.', ctx: req.ov });
+ if (req.principal.kind === 'user') {
+ if (req.principal.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'shoes.cross_site', { detail: 'A signed-in request that changes something must come from open.shoes itself.', ctx: req.ov });
+ return next();
+ }
+ return http.sendProblem(res, 403, 'shoes.person_required', { detail: 'This route is for a person, not an app.', ctx: req.ov });
+ };
+ }
+
+ /** A person (session cookie or Bearer); a write made with the session must come from this site. */
+ function requirePerson(req, res, next) {
+ if (req.principal.kind === 'user') {
+ if (req.principal.viaSession && req.method !== 'GET' && req.method !== 'HEAD' && !sameOrigin(req, config.baseUrl)) {
+ return http.sendProblem(res, 403, 'shoes.cross_site', { detail: 'A signed-in request that changes something must come from open.shoes itself.', ctx: req.ov });
+ }
+ return next();
+ }
+ if (req.principal.kind === 'app') return http.sendProblem(res, 403, 'shoes.person_required', { detail: 'This route is for a person, not an app or a service.', ctx: req.ov });
+ return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at open.shoes, or send a person\'s Network token as a Bearer.', ctx: req.ov });
+ }
+
+ return { middleware, resolve, requireCapability: require_, requirePerson };
+}
+
+module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+
+/**
+ * express.Router() whose get/post/patch/delete handlers may be async: a rejected promise goes to next(err)
+ * (the app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.
+ */
+const express = require('express');
+
+function asyncRouter() {
+ const r = express.Router();
+ for (const method of ['get', 'post', 'patch', 'delete', 'put', 'all']) {
+ const orig = r[method].bind(r);
+ r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {
+ try {
+ const out = h(req, res, next);
+ if (out && typeof out.catch === 'function') out.catch(next);
+ } catch (err) { next(err); }
+ })));
+ }
+ return r;
+}
+
+module.exports = { asyncRouter };
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+'use strict';
+
+/**
+ * Account export and deletion → Shoes (ADR-033; openvibe-sdk/account-data). The only rows a person owns here are
+ * their watches. A model, an offer, a snapshot, a drop or a cited fact is the network's public record, not a
+ * person's, so it is not exported or erased with an account; a watch is deleted outright.
+ *
+ * The database stores bare usr_ ids in the subject column. A watch is a threshold, a size and a public URL; it is
+ * never a secret.
+ */
+const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+
+const TABLES = [
+ { table: 'shoe_watches', subject: 'subject', value: (usr) => usr, file: 'watches.json', erase: 'delete' },
+];
+
+function create({ db, log = console } = {}) {
+ return createAccountData({
+ db,
+ service: 'shoes',
+ tables: TABLES,
+ note: 'Models, offers, prices and drops are the network\'s public shoe record; only your watches are here.',
+ log,
+ });
+}
+
+module.exports = { create, TABLES, TOPICS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+
+/**
+ * OpenVibe.Shoes — process entry. `node server/index.js`
+ * Listens on PORT (5180) behind nginx (deploy/).
+ */
+const { createApp } = require('./app');
+const { gracefulStop } = require('openvibe-sdk/service');
+const { startTelemetryRetention } = require('openvibe-sdk/telemetry');
+const { startSubscriptions } = require('openvibe-sdk/account-data');
+
+/**
+ * The process stop (openvibe-sdk/service): the HTTP drain runs, then the JWKS refresher stops, the jobs stop, the
+ * store closes and the Events subscriptions stop. Exported so a test can inject `exit` and `signals: false`.
+ */
+function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
+ return gracefulStop({
+ name: 'OpenVibe.Shoes', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
+ close: [
+ () => { for (const t of timers) clearInterval(t); },
+ () => ctx.keys.client.stop(),
+ () => ctx.jobs.stop(),
+ async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },
+ () => ctx.s.close(),
+ ...extra,
+ ],
+ });
+}
+
+async function start() {
+ const { app, ctx } = await createApp();
+ const { config } = ctx;
+
+ const server = app.listen(config.port, config.host, () => {
+ console.log(`[OpenVibe.Shoes] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.s.db.store})`);
+ // Warm the Network signing key immediately: a service nobody has signed in to yet must not read degraded
+ // only because the first JWKS fetch had not happened.
+ ctx.keys.ensure().catch(() => {});
+ });
+ server.keepAliveTimeout = 65_000;
+ ctx.retention = startTelemetryRetention({ db: ctx.s.db, log: ctx.log || console, olderThanDays: 14 });
+ ctx.telemetryTick = setInterval(() => { ctx.telemetry.flush().catch(() => {}); }, 60_000);
+ ctx.telemetryTick.unref();
+ ctx.keys.client.start();
+
+ // Jobs. Each refuses to start under SHOES_DRILL; tests call their runOnce().
+ ctx.jobs.start();
+
+ // The two account topics (export_requested, deleted) at OpenVibe.Events: idempotent; off without the URL, the
+ // delivery secret and the client secret. The consumer itself is mounted in server/app.js.
+ const subs = config.drill ? null : startSubscriptions({
+ eventsUrl: config.events.url,
+ endpoint: config.events.endpoint || `http://127.0.0.1:${config.port}/internal/events`,
+ secret: config.events.secrets[0] || '',
+ networkInternalUrl: config.networkInternalUrl,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ log: ctx.log || console,
+ });
+ const extra = [() => { if (subs) subs.stop(); }];
+ createLifecycle({ server, ctx, extra });
+ return { server, ctx };
+}
+
+if (require.main === module) {
+ start().catch((err) => { console.error('[OpenVibe.Shoes] failed to start:', err); process.exit(1); });
+}
+
+module.exports = { start, createLifecycle };
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+
+/**
+ * The drops job: a brand's or a store's own public release page (and any RSS feeds a host configures) is read for
+ * releases it publishes, and each one is written with the page it came from and the time it was read. No date is
+ * invented; a release without a date is not imported. Raffle entries are never automated.
+ *
+ * Off under SHOES_DRILL; tests and scripts call runOnce().
+ */
+function createDropsJob({ config, catalogue, upstreams, log = console }) {
+ let timer = null;
+ let running = false;
+
+ async function runOnce() {
+ const runId = await catalogue.startJobRun({
+ job: 'drops',
+ sourceName: config.sources.dropsSourceName,
+ sourceUrl: config.sources.dropsUrl,
+ });
+ const counts = { sources: 0, releases: 0, drops: 0, skipped: 0 };
+ try {
+ const sources = [{ url: config.sources.dropsUrl, name: config.sources.dropsSourceName, type: 'launch' }]
+ .concat(config.sources.dropFeeds.map((f) => ({ url: f.url, name: f.name || f.url, type: f.type === 'rss' ? 'rss' : 'launch' })));
+ for (const source of sources) {
+ let releases = [];
+ try {
+ releases = await upstreams.releaseCalendar(source);
+ } catch (err) {
+ log.warn(`[OpenVibe.Shoes] drops source ${source.url} failed: ${err.message}`);
+ continue;
+ }
+ counts.sources++;
+ counts.releases += releases.length;
+ for (const release of releases) {
+ const row = await catalogue.upsertDrop(release);
+ if (row) counts.drops++;
+ else counts.skipped++;
+ }
+ }
+ await catalogue.finishJobRun(runId, { ok: true, counts });
+ log.log(`[OpenVibe.Shoes] drops: ${counts.drops} releases from ${counts.sources} source(s)`);
+ return counts;
+ } catch (err) {
+ await catalogue.finishJobRun(runId, { ok: false, counts, error: err.message });
+ throw err;
+ }
+ }
+
+ function start() {
+ if (config.drill || timer) return;
+ const tick = () => {
+ if (running) return;
+ running = true;
+ runOnce().catch((err) => log.warn('[OpenVibe.Shoes] drops job failed:', err.message)).finally(() => { running = false; });
+ };
+ timer = setInterval(tick, config.jobs.dropsIntervalMs);
+ timer.unref();
+ setTimeout(tick, 12_000).unref();
+ }
+
+ function stop() {
+ if (timer) clearInterval(timer);
+ timer = null;
+ }
+
+ return { name: 'drops', runOnce, start, stop };
+}
+
+module.exports = { createDropsJob };
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+
+/**
+ * The eBay job: when the host sets an eBay developer key pair, Shoes reads the official Browse API's shoe item
+ * summaries (never a scrape) and writes them into the same catalogue as any other offer. Without the key pair it
+ * is off and says so plainly; it is never an error.
+ *
+ * Off under SHOES_DRILL; tests and scripts call runOnce().
+ */
+const parse = require('../domain/parse');
+
+function createEbayJob({ config, catalogue, upstreams, log = console }) {
+ let timer = null;
+ let running = false;
+
+ async function runOnce() {
+ if (!upstreams.ebayConfigured()) {
+ return { configured: false, offers: 0, note: 'eBay is off (SHOES_EBAY_CLIENT_ID and SHOES_EBAY_CLIENT_SECRET are not set).' };
+ }
+ const runId = await catalogue.startJobRun({
+ job: 'ebay',
+ sourceName: 'eBay Browse API',
+ sourceUrl: `${config.sources.ebay.apiUrl}/buy/browse/v1/item_summary/search`,
+ });
+ const counts = { items: 0, offers: 0, skipped: 0 };
+ try {
+ const out = await upstreams.ebayShoes();
+ counts.items = out.items.length;
+ for (const item of out.items) {
+ if (!parse.isShoe(item.title)) { counts.skipped++; continue; }
+ const row = await catalogue.upsertOffer({
+ ...item,
+ modelName: item.title,
+ colourway: parse.colourwayOf(item.title),
+ raw: { itemId: item.sourceId, condition: item.condition || null },
+ });
+ if (row) counts.offers++;
+ else counts.skipped++;
+ }
+ await catalogue.finishJobRun(runId, { ok: true, counts });
+ log.log(`[OpenVibe.Shoes] ebay: ${counts.offers} offers from ${counts.items} items`);
+ return { configured: true, ...counts };
+ } catch (err) {
+ await catalogue.finishJobRun(runId, { ok: false, counts, error: err.message });
+ throw err;
+ }
+ }
+
+ function start() {
+ if (config.drill || timer || !upstreams.ebayConfigured()) return;
+ const tick = () => {
+ if (running) return;
+ running = true;
+ runOnce().catch((err) => log.warn('[OpenVibe.Shoes] ebay job failed:', err.message)).finally(() => { running = false; });
+ };
+ timer = setInterval(tick, config.jobs.ebayIntervalMs);
+ timer.unref();
+ setTimeout(tick, 20_000).unref();
+ }
+
+ function stop() {
+ if (timer) clearInterval(timer);
+ timer = null;
+ }
+
+ return { name: 'ebay', runOnce, start, stop };
+}
+
+module.exports = { createEbayJob };
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+'use strict';
+
+/**
+ * The jobs, together: offers (OpenVibe.Deals), drops (official release pages) and eBay (official Browse API, only
+ * with a key pair). Each refuses to start under SHOES_DRILL, where tests and a restore never touch a source.
+ */
+const { createOffersJob } = require('./offers');
+const { createDropsJob } = require('./drops');
+const { createEbayJob } = require('./ebay');
+
+function createJobs({ config, catalogue, upstreams, log = console }) {
+ const offers = createOffersJob({ config, catalogue, upstreams, log });
+ const drops = createDropsJob({ config, catalogue, upstreams, log });
+ const ebay = createEbayJob({ config, catalogue, upstreams, log });
+ const all = [offers, drops, ebay];
+
+ function start() {
+ if (config.drill) return;
+ for (const job of all) job.start();
+ }
+
+ function stop() {
+ for (const job of all) job.stop();
+ }
+
+ return { offers, drops, ebay, all, start, stop };
+}
+
+module.exports = { createJobs };
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+'use strict';
+
+/**
+ * The offers job: OpenVibe.Deals already aggregates stores' offers, so Shoes reads its public API (the same
+ * newest-offer pages any person can read), keeps the footwear, and writes each one into Shoes' own catalogue with
+ * its price, shipping, condition, size when the source names one, the time the source checked it and a stale flag.
+ *
+ * The brand and the clean model name come from the offer's product on Deals (its public product API). The
+ * classifier (server/domain/parse.js) decides what is footwear; a title that names no shoe is never imported.
+ *
+ * Off under SHOES_DRILL; tests and scripts call runOnce().
+ */
+const parse = require('../domain/parse');
+
+function createOffersJob({ config, catalogue, upstreams, log = console }) {
+ let timer = null;
+ let running = false;
+
+ function shoeCandidate(offer) {
+ const productName = offer && offer.product && offer.product.name ? offer.product.name : '';
+ return parse.isShoe(offer && offer.title, productName);
+ }
+
+ async function runOnce({ pages = config.sources.offersPages, maxProductLookups = config.sources.productLookups } = {}) {
+ const runId = await catalogue.startJobRun({
+ job: 'offers',
+ sourceName: 'OpenVibe.Deals',
+ sourceUrl: `${config.sources.dealsUrl}/api/v1/offers`,
+ });
+ const counts = { pages: 0, seen: 0, candidates: 0, product_lookups: 0, offers: 0, skipped: 0 };
+ const brands = new Map();
+ try {
+ const total = Math.max(1, Math.min(200, Number(pages) || 1));
+ for (let page = 1; page <= total; page++) {
+ const offers = await upstreams.dealsOffers(page);
+ if (!offers.length) break;
+ counts.pages++;
+ counts.seen += offers.length;
+ for (const offer of offers) {
+ if (!shoeCandidate(offer)) continue;
+ counts.candidates++;
+ const observation = offer.latest_observation || {};
+ const product = offer.product || {};
+ let brand = null;
+ if (product.slug && counts.product_lookups < maxProductLookups) {
+ if (brands.has(product.slug)) brand = brands.get(product.slug);
+ else {
+ const p = await upstreams.dealsProduct(product.slug);
+ counts.product_lookups++;
+ brand = p && p.brand ? p.brand : null;
+ brands.set(product.slug, brand);
+ }
+ }
+ const modelName = product.name || offer.title;
+ const checkedAt = observation.observed_at || observation.recorded_at || new Date().toISOString();
+ const row = await catalogue.upsertOffer({
+ source: 'deals',
+ sourceId: offer.id,
+ title: offer.title,
+ modelName,
+ brand,
+ url: offer.link || offer.url,
+ store: offer.store && offer.store.domain,
+ storeName: offer.store && offer.store.name,
+ price: observation.price,
+ listPrice: observation.list_price,
+ currency: observation.currency || 'USD',
+ shipping: observation.shipping,
+ condition: observation.condition,
+ size: parse.sizeOf(offer.title),
+ colourway: parse.colourwayOf(offer.title),
+ checkedAt,
+ raw: {
+ id: offer.id, slug: offer.slug, product: product.slug || null,
+ observed_at: observation.observed_at || null,
+ },
+ });
+ if (row) counts.offers++;
+ else counts.skipped++;
+ if (config.sources.requestGapMs) await upstreams.sleep(config.sources.requestGapMs / 2);
+ }
+ if (page < total && config.sources.requestGapMs) await upstreams.sleep(config.sources.requestGapMs);
+ }
+ await catalogue.finishJobRun(runId, { ok: true, counts });
+ log.log(`[OpenVibe.Shoes] offers: ${counts.offers} offers from ${counts.candidates} shoe candidates over ${counts.pages} pages`);
+ return counts;
+ } catch (err) {
+ await catalogue.finishJobRun(runId, { ok: false, counts, error: err.message });
+ throw err;
+ }
+ }
+
+ function start() {
+ if (config.drill || timer) return;
+ const tick = () => {
+ if (running) return;
+ running = true;
+ runOnce().catch((err) => log.warn('[OpenVibe.Shoes] offers job failed:', err.message)).finally(() => { running = false; });
+ };
+ timer = setInterval(tick, config.jobs.offersIntervalMs);
+ timer.unref();
+ setTimeout(tick, 8_000).unref();
+ }
+
+ function stop() {
+ if (timer) clearInterval(timer);
+ timer = null;
+ }
+
+ return { name: 'offers', runOnce, start, stop, shoeCandidate };
+}
+
+module.exports = { createOffersJob };
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+'use strict';
+
+/**
+ * Truthful readiness for GET /api/ready (openvibe-shared/ready): the database is required; the Network signing key,
+ * the OAuth client and Valkey are optional. The key store is warmed right after listen, so a service nobody has
+ * signed in to yet is not degraded only because the first fetch had not happened.
+ */
+const { jwksStatus } = require('openvibe-sdk/auth');
+const { createReadiness } = require('openvibe-shared/ready');
+
+function createServiceReadiness({ s, config, valkey = null, release = null }) {
+ return createReadiness({
+ service: 'shoes',
+ release,
+ checks: [
+ {
+ name: 'db', required: true,
+ check: async () => {
+ const r = await s.db.ready();
+ return r.ok ? { ok: true, detail: r.detail } : r.error;
+ },
+ },
+ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-caller limits count in this process only' }) },
+ {
+ name: 'network_jwks', required: false,
+ check: () => {
+ const statuses = jwksStatus();
+ if (!statuses.length) return 'no JWKS clients (misconfigured)';
+ const first = statuses[0];
+ if (!first.ready) return 'Network signing key not loaded yet: sign-in and the API are unavailable';
+ return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };
+ },
+ },
+ { name: 'oauth_client', required: false, check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete') },
+ ],
+ });
+}
+
+module.exports = { createServiceReadiness };
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+
+/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }
+const raw = (s) => new Raw(s);
+function html(strings, ...values) {
+ let out = strings[0];
+ values.forEach((v, i) => { out += render(v) + strings[i + 1]; });
+ return raw(out);
+}
+function render(v) {
+ if (v == null || v === false) return '';
+ if (v instanceof Raw) return v.s;
+ if (Array.isArray(v)) return v.map(render).join('');
+ return esc(v);
+}
+
+const code = (s) => html`<code>${s}</code>`;
+const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
+
+function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {
+ if (!rows.length) return html`<p class="muted">${empty}</p>`;
+ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;
+}
+
+const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;
+const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
+
+module.exports = { esc, html, raw, Raw, render, code, time, table, notice, badge };
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
+'use strict';
+
+/**
+ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.
+ * The room's accent comes from openvibe-shared/rooms (`shoes`, #FF7E76 dark / #B02A2D light) and the navigation's
+ * name is `Shoes`; the domain is always written `open.shoes`.
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const appIcon = require('openvibe-shared/app-icon');
+const shell = require('openvibe-shared/shell');
+const rooms = require('openvibe-shared/rooms');
+const ovServe = require('openvibe-shared/serve');
+const cache = require('openvibe-shared/cache-policy');
+const { esc } = require('./html');
+
+const NETWORK_URL = 'https://openvibe.network';
+const SITE_NAME = 'OpenVibe.Shoes';
+const MENU_NAME = 'Shoes';
+const TAGLINE = 'Every shoe, every market, the right price.';
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
+const NAV = [
+ { label: 'Home', href: '/', icon: 'fa-shoe-prints' },
+ { label: 'Shoes', href: '/shoes', icon: 'fa-magnifying-glass' },
+ { label: 'Drops', href: '/drops', icon: 'fa-calendar-days' },
+ { label: 'Watches', href: '/watches', icon: 'fa-bell' },
+ { label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
+];
+const API_BASE = 'https://open.shoes';
+
+const hashes = new Map();
+function assetVersion(rel) {
+ if (hashes.has(rel)) return hashes.get(rel);
+ let v = 'dev';
+ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
+ hashes.set(rel, v);
+ return v;
+}
+const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
+
+let RELEASE = 'dev';
+function setRelease(id) { if (id) RELEASE = String(id); }
+
+/**
+ * o: title, description, body (trusted HTML), viewer, config, path, active, robots, canonical, jsonLd
+ */
+function layout(o) {
+ const config = o.config || { baseUrl: API_BASE };
+ const signedIn = o.viewer && o.viewer.kind === 'user';
+ const pathNow = o.path || '/';
+ const active = o.active || pathNow;
+ const canonical = o.canonical === undefined ? `${String(config.baseUrl).replace(/\/+$/, '')}${pathNow.split('?')[0]}` : o.canonical;
+ const navbar = {
+ service: 'shoes',
+ apiBase: NETWORK_URL,
+ links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && pathNow.startsWith(`${l.href}/`)) })),
+ appBar: [
+ { label: 'Shoes', href: '/shoes', icon: 'fa-magnifying-glass' },
+ { label: 'Drops', href: '/drops', icon: 'fa-calendar-days' },
+ { label: 'Watches', href: '/watches', icon: 'fa-bell' },
+ ],
+ silentLogin: `${String(config.baseUrl).replace(/\/+$/, '')}/auth/login?silent=1&next={url}`,
+ sessionUrl: '/auth/me',
+ loginUrl: '/auth/login?next={path}',
+ logoutUrl: '/auth/logout?next={path}',
+ notificationsRealtime: true,
+ history: { type: 'page', title: o.title || SITE_NAME },
+ };
+ const footer = { service: 'shoes', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };
+ return shell.page({
+ name: SITE_NAME,
+ lang: 'en',
+ title: o.title || `${SITE_NAME}: ${TAGLINE}`,
+ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',
+ siteName: SITE_NAME,
+ description: o.description || `${SITE_NAME} — ${TAGLINE} Every price is all-in, with its store, the time it was checked and a stale flag.`,
+ canonical,
+ robots: o.robots || (o.index ? 'index, follow' : 'noindex, follow'),
+ jsonLd: (o.jsonLd || []).filter(Boolean),
+ home: '/',
+ navLinks: NAV,
+ navbar,
+ footer,
+ head: `${appIcon.headTags({ site: 'shoes' })}
+<link rel="stylesheet" href="${esc(ovServe.url('kit.css'))}">
+<link rel="stylesheet" href="${esc(ovServe.url('room.css'))}">
+<link rel="stylesheet" href="${esc(ovServe.url('showcase.css'))}">
+<link rel="stylesheet" href="${asset('css/shoes.css')}">
+<script src="${esc(ovServe.url('kit-web.js'))}" defer></script>
+<script src="${esc(ovServe.url('motion.js'))}" defer></script>
+<meta name="ov-boost" content="shoes@${esc(RELEASE)}">`,
+ body: `<a class="skip" href="#main">Skip to content</a>
+<div id="navbar-mount"></div>
+<noscript><div class="account-bar" role="navigation" aria-label="Account">${signedIn
+ ? `Signed in as <strong>${esc(o.viewer.displayName || o.viewer.username || 'you')}</strong>`
+ : '<a href="/auth/login">Sign in with OpenVibe</a>'}</div></noscript>
+<main id="main" class="sh ov-room" style="${rooms.style('shoes')}">${o.body}</main>
+<script>window.__OV_PAGE = ${JSON.stringify({ navbar, footer }).replace(/</g, '\\u003c')};
+document.addEventListener('DOMContentLoaded', function () { try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ } });</script>`,
+ });
+}
+
+/** Send a page; a page rendered for a signed-in person is private and never cached. */
+function send(res, status, o) {
+ const personal = o.viewer && o.viewer.kind === 'user';
+ if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ public: true, maxAge: 60 })));
+ res.set('Vary', 'Cookie');
+ res.status(status).type('html').send(layout(o));
+}
+
+module.exports = { layout, send, renderPage: layout, asset, assetVersion, setRelease, SITE_NAME, MENU_NAME, TAGLINE, NETWORK_URL, NAV, API_BASE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,240 @@
@@ -0,0 +1,240 @@
+'use strict';
+
+/**
+ * Everything Shoes reads from the network and from the public web.
+ *
+ * OpenVibe.Deals the network's own offer aggregator (its public API): the offers, the stores and the
+ * products. No key: these are the same reads any person can make.
+ * eBay the official Browse API (OAuth2 client credentials) when a key pair is set; off otherwise.
+ * a release page a brand's or a store's own public release calendar (an official page, read as a page).
+ * OpenVibe.Watch the watch service: a watch for a person (a service token; the person in X-OV-Subject).
+ * OpenVibe.Today the drops calendar's reminders (the person's own Network token as a Bearer).
+ *
+ * A missing key is never an error: the part that needs it is off, with a plain note.
+ */
+const egress = require('openvibe-shared/egress');
+const drops = require('./domain/drops');
+const parse = require('./domain/parse');
+
+class UpstreamError extends Error {
+ constructor(message, { status = 502, code = 'shoes.upstream_failed' } = {}) {
+ super(message);
+ this.status = status;
+ this.code = code;
+ }
+}
+
+function createUpstreams({ config, fetchImpl = globalThis.fetch, log = console, urlCheck = (u) => egress.assertPublicUrl(u) } = {}) {
+ const timeoutMs = config.sources.timeoutMs;
+
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+ async function request(url, { method = 'GET', headers = {}, body = null, json = false, timeout = timeoutMs } = {}) {
+ for (let attempt = 0; ; attempt++) {
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), timeout);
+ try {
+ const res = await fetchImpl(url, {
+ method,
+ headers: { 'user-agent': 'OpenVibe.Shoes/0.1 (+https://open.shoes)', accept: 'application/json, text/html;q=0.9', ...headers },
+ body,
+ signal: ctrl.signal,
+ });
+ const text = await res.text();
+ if (res.status === 429 && attempt < 2) {
+ const retry = Number(res.headers.get('retry-after'));
+ await sleep(Math.min(10_000, Math.max(500, (Number.isFinite(retry) && retry > 0 ? retry : 1) * 1000)));
+ continue;
+ }
+ if (!res.ok) throw new UpstreamError(`${method} ${url} answered ${res.status}`, { status: res.status === 429 ? 429 : 502, code: res.status === 429 ? 'shoes.rate_limited' : 'shoes.upstream_failed' });
+ if (json) { try { return JSON.parse(text); } catch { throw new UpstreamError(`${url} did not answer JSON`, { status: 502 }); } }
+ return text;
+ } catch (err) {
+ if (err instanceof UpstreamError) throw err;
+ throw new UpstreamError(`${method} ${url} failed: ${err && err.message ? err.message : 'network error'}`, { status: 502 });
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+ }
+
+ // ── OpenVibe.Deals ──────────────────────────────────────
+ const dealsBase = () => config.sources.dealsUrl.replace(/\/+$/, '');
+
+ /** The newest offers on OpenVibe.Deals (its public API), one page at a time. */
+ async function dealsOffers(page = 1) {
+ const body = await request(`${dealsBase()}/api/v1/offers?sort=new&page=${encodeURIComponent(page)}`, { json: true });
+ return Array.isArray(body && body.offers) ? body.offers : [];
+ }
+
+ /** The offers of one store on OpenVibe.Deals. */
+ async function dealsStoreOffers(domain) {
+ const body = await request(`${dealsBase()}/api/v1/stores/${encodeURIComponent(domain)}`, { json: true });
+ return Array.isArray(body && body.offers) ? body.offers : [];
+ }
+
+ /** One product on OpenVibe.Deals (its own page, with the brand). */
+ async function dealsProduct(slug) {
+ try {
+ const body = await request(`${dealsBase()}/api/v1/products/${encodeURIComponent(slug)}`, { json: true });
+ return body && body.product ? body.product : null;
+ } catch (err) {
+ log.warn('[OpenVibe.Shoes] deals product failed:', err.message);
+ return null;
+ }
+ }
+
+ // ── eBay Browse API (official) ──────────────────────────
+ let ebayToken = null;
+ let ebayTokenAt = 0;
+
+ function ebayConfigured() {
+ return Boolean(config.sources.ebay.clientId && config.sources.ebay.clientSecret);
+ }
+
+ async function ebayAccessToken() {
+ if (!ebayConfigured()) return null;
+ if (ebayToken && Date.now() - ebayTokenAt < 50 * 60_000) return ebayToken;
+ const basic = Buffer.from(`${config.sources.ebay.clientId}:${config.sources.ebay.clientSecret}`).toString('base64');
+ const body = await request(`${config.sources.ebay.apiUrl}/identity/v1/oauth2/token`, {
+ method: 'POST',
+ headers: { authorization: `Basic ${basic}`, 'content-type': 'application/x-www-form-urlencoded' },
+ body: 'grant_type=client_credentials&scope=https%3A%2F%2Fapi.ebay.com%2Foauth%2Fapi_scope',
+ json: true,
+ });
+ if (!body || !body.access_token) throw new UpstreamError('eBay did not issue a token');
+ ebayToken = body.access_token;
+ ebayTokenAt = Date.now();
+ return ebayToken;
+ }
+
+ /**
+ * The official Browse API's item summaries for shoes. Each item keeps eBay's own id, URL, price, shipping,
+ * condition and seller; a size is read from the title only when the title names one.
+ */
+ async function ebayShoes({ q = 'shoes', limit = config.sources.ebay.limit } = {}) {
+ if (!ebayConfigured()) return { configured: false, items: [] };
+ const token = await ebayAccessToken();
+ const params = new URLSearchParams({
+ q: String(q).slice(0, 100),
+ category_ids: String(config.sources.ebay.categoryId),
+ limit: String(Math.max(1, Math.min(200, limit))),
+ sort: 'newlyListed',
+ });
+ const body = await request(`${config.sources.ebay.apiUrl}/buy/browse/v1/item_summary/search?${params.toString()}`, {
+ headers: { authorization: `Bearer ${token}`, 'x-ebay-c-marketplace-id': config.sources.ebay.marketplace },
+ json: true,
+ });
+ const items = Array.isArray(body && body.itemSummaries) ? body.itemSummaries : [];
+ return {
+ configured: true,
+ items: items.map((it) => {
+ const price = it.price && parse.amountOf(it.price.value);
+ const ship = it.shippingOptions && it.shippingOptions[0] && it.shippingOptions[0].shippingCost
+ ? parse.amountOf(it.shippingOptions[0].shippingCost.value) : null;
+ let store = 'ebay.com';
+ try { store = new URL(it.itemWebUrl).hostname.replace(/^www\./, ''); } catch { /* keep ebay.com */ }
+ return {
+ source: 'ebay',
+ sourceId: String(it.itemId || ''),
+ title: String(it.title || ''),
+ url: String(it.itemWebUrl || ''),
+ store,
+ storeName: (it.seller && it.seller.username) ? `eBay seller ${it.seller.username}` : 'eBay',
+ price,
+ shipping: ship,
+ condition: parse.conditionOf(it.condition),
+ size: parse.sizeOf(it.title),
+ currency: (it.price && it.price.currency) || 'USD',
+ checkedAt: new Date().toISOString(),
+ raw: it,
+ };
+ }).filter((x) => x.sourceId && x.url && x.price != null),
+ };
+ }
+
+ // ── Official release pages ──────────────────────────────
+ /** The releases on a brand's or a store's own release calendar. */
+ async function releaseCalendar({ url = config.sources.dropsUrl, name = config.sources.dropsSourceName, type = 'launch' } = {}) {
+ const html = await request(url, { headers: { accept: 'text/html' } });
+ const fetchedAt = new Date().toISOString();
+ if (type === 'rss') return drops.parseRss(html, { sourceName: name, sourceUrl: url, fetchedAt });
+ return drops.parseLaunchCalendar(html, { sourceName: name, sourceUrl: url, fetchedAt });
+ }
+
+ // ── OpenVibe.Watch ──────────────────────────────────────
+ /**
+ * Create a watch for a person in OpenVibe.Watch. The service token carries the capability
+ * watch.watch.manage; the person is named in X-OV-Subject. Without a token the watch is not created there
+ * and the caller says so plainly.
+ */
+ async function createWatch(body, { subject }) {
+ if (!config.watch.token) return { ok: false, code: 'shoes.watch_not_configured', detail: 'OpenVibe.Watch is not connected yet (no service token).' };
+ const url = `${config.watch.internalUrl.replace(/\/+$/, '')}/api/v1/watches`;
+ try {
+ const res = await request(url, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ authorization: `Bearer ${config.watch.token}`,
+ 'x-ov-subject': String(subject || ''),
+ },
+ body: JSON.stringify(body),
+ json: true,
+ });
+ const watch = res && res.watch ? res.watch : res;
+ return { ok: true, watch };
+ } catch (err) {
+ return { ok: false, code: err.code || 'shoes.watch_failed', detail: err.message };
+ }
+ }
+
+ // ── OpenVibe.Today ──────────────────────────────────────
+ /** Put a drop on a person's Today agenda. The person's own Network token is the Bearer. */
+ async function createTodayEvent(body, { token } = {}) {
+ if (!token) return { ok: false, code: 'shoes.today_no_token', detail: 'This session has no Network token, so the reminder was not added.' };
+ const url = `${config.today.internalUrl.replace(/\/+$/, '')}/api/v1/events`;
+ try {
+ const res = await request(url, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
+ body: JSON.stringify(body),
+ json: true,
+ });
+ const event = res && res.event ? res.event : res;
+ return { ok: true, event };
+ } catch (err) {
+ return { ok: false, code: err.code || 'shoes.today_failed', detail: err.message };
+ }
+ }
+
+ /**
+ * The rules for a URL a person pasted: it must be an http(s) address on the public internet (the same rule
+ * every OpenVibe service uses for a user-chosen address, openvibe-shared/egress). Facebook Marketplace has no
+ * public listings API and its terms forbid reading it directly, so a Marketplace link is accepted as a watch
+ * and Watch's own fetch rules decide what it may see.
+ */
+ async function checkPastedUrl(value) {
+ const raw = String(value == null ? '' : value).trim();
+ if (!raw) return { ok: false, code: 'shoes.url_required', detail: 'Paste the link to the listing.' };
+ if (raw.length > 2000) return { ok: false, code: 'shoes.url_too_long', detail: 'That link is too long.' };
+ let url;
+ try { url = new URL(raw); } catch { return { ok: false, code: 'shoes.url_invalid', detail: 'That is not a link Shoes can read.' }; }
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
+ return { ok: false, code: 'shoes.url_invalid', detail: 'Only http and https links can be watched.' };
+ }
+ try {
+ await urlCheck(url.toString());
+ } catch (err) {
+ return { ok: false, code: 'shoes.url_not_public', detail: 'That address is not on the public internet, so it cannot be watched.' };
+ }
+ return { ok: true, url: url.toString(), host: url.hostname };
+ }
+
+ return {
+ request, dealsOffers, sleep, dealsStoreOffers, dealsProduct, ebayShoes, ebayConfigured, releaseCalendar,
+ createWatch, createTodayEvent, checkPastedUrl, UpstreamError,
+ };
+}
+
+module.exports = { createUpstreams, UpstreamError };
new file mode 100644
new file mode 100644
@@ -0,0 +1,398 @@
@@ -0,0 +1,398 @@
+'use strict';
+
+/**
+ * The Shoes room's pages (plan 2026-10-28 §5.6). Server-rendered and whole without JavaScript: every action is a
+ * plain form or link. The room's front door is sc.room (hero, proof, rails, scenes, explore, developers, questions,
+ * the rest of OpenVibe); every number on it is read from Shoes' own catalogue or from the source itself.
+ *
+ * home({ facets, dropping, dropsToday, popular, curveModel, curve, sceneDrop, jobs, brands })
+ * shoesIndex({ models, facets, query, page, total })
+ * shoe({ model, offers, history, curve, facts, signedIn })
+ * drops({ drops, signedIn, notice })
+ * watches({ watches, signedIn })
+ * developers({ baseUrl })
+ * signIn({ title, next })
+ * notFound()
+ * Ids and classes are stable and start with `sh-`.
+ */
+const kit = require('openvibe-shared/kit');
+const sc = require('openvibe-shared/showcase');
+const rooms = require('openvibe-shared/rooms');
+
+const { esc, icon, card, rail } = kit;
+const ROOM = rooms.get('shoes');
+const SITE = 'OpenVibe.Shoes';
+const money = (n, cur = 'USD') => {
+ if (n == null || n === '') return '—';
+ const v = Number(n);
+ if (!Number.isFinite(v)) return '—';
+ const s = v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+ return cur === 'USD' ? `$${s}` : `${s} ${esc(cur)}`;
+};
+const pct = (n) => {
+ if (n == null || !Number.isFinite(Number(n))) return '—';
+ return `${Number(n) > 0 ? '+' : ''}${Number(n).toFixed(1)}%`.replace(/^-/, '−');
+};
+const dayShort = (iso) => { const t = Date.parse(iso || ''); return Number.isFinite(t) ? new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }) : ''; };
+const dayLong = (iso) => { const t = Date.parse(iso || ''); return Number.isFinite(t) ? new Date(t).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }) : '—'; };
+const clock = (iso) => { const t = Date.parse(iso || ''); return Number.isFinite(t) ? new Date(t).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' }) : ''; };
+const checked = (iso, now = Date.now()) => {
+ const t = Date.parse(iso || '');
+ if (!Number.isFinite(t)) return 'never checked';
+ const mins = Math.max(0, Math.round((now - t) / 60_000));
+ if (mins < 2) return 'checked just now';
+ if (mins < 90) return `checked ${mins} min ago`;
+ const h = Math.round(mins / 60);
+ if (h < 48) return `checked ${h} h ago`;
+ return `checked ${dayShort(iso)} at ${clock(iso)} UTC`;
+};
+const stale = (offer) => (offer.stale ? '<span class="sh-stale">stale</span>' : '<span class="sh-fresh">fresh</span>');
+const link = (href, text, cls = '') => `<a${cls ? ` class="${esc(cls)}"` : ''} href="${esc(href)}">${esc(text)}</a>`;
+const sourceLine = (name, url) => (url ? `<a href="${esc(url)}" rel="noopener">${esc(name)}</a>` : esc(name));
+
+/** A card for a model: its price from, how many offers, when it was checked. */
+function modelCard(m) {
+ const price = m.price_min != null ? `from ${money(m.price_min)}` : 'price not known yet';
+ return card({
+ href: `/shoe/${encodeURIComponent(m.slug)}`,
+ title: `${m.brand} ${m.model}`,
+ type: 'shoe',
+ corner: m.price_min != null ? money(m.price_min) : '',
+ badge: m.drop_amount ? `−${money(m.drop_amount)}` : '',
+ who: m.brand,
+ count: [m.offer_count, m.offer_count === 1 ? 'offer' : 'offers'],
+ when: m.checked_at,
+ peek: `${price}${m.stale_count ? ` · ${m.stale_count} stale` : ''}`,
+ });
+}
+
+/** One row per model, colourway and release moment: a source's variant rows (a style code each) become one release. */
+function groupDrops(list = []) {
+ const groups = new Map();
+ for (const d of list) {
+ const key = [d.brand, d.model, d.colourway || '', String(d.release_at).slice(0, 16)].join('|');
+ if (!groups.has(key)) {
+ groups.set(key, { ...d, sizes: [...(d.sizes || [])], variants: 1 });
+ continue;
+ }
+ const g = groups.get(key);
+ g.variants++;
+ g.sizes = [...new Set([...g.sizes, ...(d.sizes || [])])].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
+ if (d.price != null && (g.price == null || d.price < g.price)) g.price = d.price;
+ }
+ return [...groups.values()].sort((a, b) => Date.parse(a.release_at) - Date.parse(b.release_at));
+}
+
+/** A card for a release. */
+function dropCard(d) {
+ return card({
+ href: `/drops#drop-${encodeURIComponent(d.id)}`,
+ title: `${d.brand} ${d.model}${d.colourway ? ` · ${d.colourway}` : ''}`,
+ type: 'event',
+ corner: d.price != null ? money(d.price, d.currency) : '',
+ badge: dayShort(d.release_at),
+ who: d.source_name,
+ peek: `${dayLong(d.release_at)}${d.sizes.length ? ` · ${d.sizes.length} sizes` : ''}`,
+ });
+}
+
+/** The small line chart of a model's own snapshots. No made-up points: one snapshot draws one dot. */
+function historyChart(points) {
+ if (!points.length) return '<p class="sh-muted">No snapshot yet. The first check writes one point; the line grows as Shoes checks again.</p>';
+ const w = 640, h = 180, pad = 24;
+ const prices = points.map((p) => p.price).filter((p) => p != null);
+ const lo = Math.min(...prices), hi = Math.max(...prices);
+ const span = hi - lo || 1;
+ const x = (i) => pad + (points.length === 1 ? (w - pad * 2) / 2 : (i * (w - pad * 2)) / (points.length - 1));
+ const y = (p) => h - pad - ((p - lo) / span) * (h - pad * 2);
+ const line = points.map((p, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(p.price).toFixed(1)}`).join(' ');
+ const dots = points.map((p, i) => `<circle cx="${x(i).toFixed(1)}" cy="${y(p.price).toFixed(1)}" r="3"><title>${esc(`${p.day}: ${money(p.price)}`)}</title></circle>`).join('');
+ return `<figure class="sh-chart"><svg viewBox="0 0 ${w} ${h}" role="img" aria-label="Lowest all-in price by day, ${esc(points[0].day)} to ${esc(points[points.length - 1].day)}">`
+ + `<line class="sh-chart-axis" x1="${pad}" y1="${h - pad}" x2="${w - pad}" y2="${h - pad}"></line>`
+ + `<line class="sh-chart-axis" x1="${pad}" y1="${pad}" x2="${pad}" y2="${h - pad}"></line>`
+ + `<path class="sh-chart-line" d="${line}" fill="none"></path>${dots}</svg>`
+ + `<figcaption><span>Lowest all-in price by day</span><b>${money(points[points.length - 1].price)}</b><small>${esc(points[0].day)} → ${esc(points[points.length - 1].day)} · ${points.length} day${points.length === 1 ? '' : 's'} of Shoes' own snapshots</small></figcaption></figure>`;
+}
+
+/** The size-price curve as bars (real points only). */
+function curveBars(curve) {
+ if (!curve || !curve.points.length) return '<p class="sh-muted">Sizes are not stated by the sources yet, so there is no size-price curve. It appears when a store names the size.</p>';
+ const max = Math.max(...curve.points.map((p) => p.min_price || 0), 1);
+ return `<div class="sh-curve" role="list" aria-label="Lowest all-in price by size">${curve.points.slice(0, 40).map((p) => `<div class="sh-curve-point" role="listitem">`
+ + `<span class="sh-curve-size">${esc(p.size)}</span><span class="sh-curve-bar" style="--h:${Math.max(4, Math.round(((p.min_price || 0) / max) * 100))}%"></span>`
+ + `<b>${money(p.min_price)}</b><small>${p.vs_median_percent == null ? '' : `${pct(p.vs_median_percent)} vs median`}</small></div>`).join('')}</div>`;
+}
+
+/** A size-price table, when sizes are known. */
+function curveTable(curve) {
+ if (!curve || !curve.points.length) return '<p class="sh-muted">Not known yet: the offers we read name no size. A size-price curve is shown only from offers that state one.</p>';
+ return `<div class="sh-table-wrap"><table class="sh-table"><caption>Lowest all-in price per size, against the model's median size</caption><thead><tr><th scope="col">Size</th><th scope="col">Lowest all-in</th><th scope="col">vs median (${money(curve.median)})</th><th scope="col">Offers</th><th scope="col">Stores</th><th scope="col">Checked</th></tr></thead><tbody>`
+ + curve.points.map((p) => `<tr><td>${esc(p.size)}</td><td>${money(p.min_price)}</td><td>${pct(p.vs_median_percent)}</td><td>${p.offers}</td><td>${p.stores}</td><td>${esc(checked(p.checked_at))}</td></tr>`).join('')
+ + '</tbody></table></div>';
+}
+
+function offersTable(offers) {
+ if (!offers.length) return '<p class="sh-muted">No live offer yet. The next check may bring one.</p>';
+ return `<div class="sh-table-wrap"><table class="sh-table sh-offers"><caption>Every current offer, cheapest all-in first</caption><thead><tr><th scope="col">Store</th><th scope="col">All-in</th><th scope="col">Price</th><th scope="col">Shipping</th><th scope="col">Size</th><th scope="col">Condition</th><th scope="col">Colourway</th><th scope="col">Checked</th><th scope="col">Freshness</th></tr></thead><tbody>`
+ + offers.map((o) => `<tr><td><a href="${esc(o.url)}" rel="noopener nofollow">${esc(o.store_name || o.store)}</a><br><small>${esc(o.title)}</small></td>`
+ + `<td><b>${money(o.all_in, o.currency)}</b>${o.drop_from_list ? `<br><small class="sh-good">−${money(o.drop_from_list, o.currency)} from list</small>` : ''}</td>`
+ + `<td>${money(o.price, o.currency)}</td><td>${o.shipping_known ? money(o.shipping, o.currency) : '<span class="sh-muted">not stated</span>'}</td>`
+ + `<td>${o.size ? esc(o.size) : '<span class="sh-muted">not stated</span>'}</td><td>${o.condition ? esc(o.condition) : '<span class="sh-muted">not stated</span>'}</td>`
+ + `<td>${o.colourway ? esc(o.colourway) : '<span class="sh-muted">not stated</span>'}</td><td>${esc(checked(o.checked_at))}</td><td>${stale(o)}</td></tr>`).join('')
+ + '</tbody></table></div>';
+}
+
+function factsSection(facts) {
+ const kinds = [['specs', 'Specs'], ['fit', 'Fit notes'], ['release', 'Release'], ['care', 'Care and repair']];
+ return `<section class="sh-facts" aria-labelledby="sh-facts-h"><h2 id="sh-facts-h">Specs, fit, release and care</h2><p class="sh-muted">Only from cited open sources. When a source has not said it, the page says “not known yet”; Shoes never invents a spec.</p>`
+ + kinds.map(([kind, label]) => {
+ const rows = facts.filter((f) => f.kind === kind);
+ return `<div class="sh-fact"><h3>${label}</h3>${rows.length ? `<dl>${rows.map((f) => `<dt>${esc(f.label)}</dt><dd>${esc(f.value)} <small>· ${sourceLine(f.source_name, f.source_url)}, read ${esc(dayShort(f.fetched_at))}</small></dd>`).join('')}</dl>` : '<p class="sh-muted">Not known yet.</p>'}</div>`;
+ }).join('') + '</section>';
+}
+
+function watchForm({ model, sizes, signedIn, next }) {
+ if (!signedIn) return `<div class="sh-watch"><h2>Tell me when this drops below $X</h2><p>A price watch needs a person. ${link(`/auth/login?next=${encodeURIComponent(next)}`, 'Sign in with OpenVibe')} and the watch goes to OpenVibe.Watch.</p></div>`;
+ return `<form class="sh-watch" method="post" action="/shoe/${encodeURIComponent(model.slug)}/watch"><h2>Tell me when this drops below $X at my size</h2>`
+ + `<p>Watch the all-in price. Shoes hands the watch to OpenVibe.Watch, which carries the alert to your notifications.</p>`
+ + `<div class="sh-form-row"><label>Lowest all-in price <input type="number" name="threshold" min="1" max="100000" step="0.01" required placeholder="90"></label>`
+ + `<label>Size <select name="size"><option value="">Any size</option>${sizes.map((s) => `<option value="${esc(s)}">${esc(s)}</option>`).join('')}</select></label>`
+ + `<label>Condition <select name="condition"><option value="">Any</option><option value="new">New</option><option value="used">Used</option><option value="deadstock">Deadstock</option></select></label>`
+ + `<button class="sh-btn" type="submit">${icon('bell')} Watch this price</button></div></form>`;
+}
+
+function searchForm({ q = '', facets = {}, action = '/shoes', values = {} }) {
+ const brands = (facets.brands || []).slice(0, 40);
+ const sizes = facets.sizes || [];
+ return `<form class="sh-search" role="search" method="get" action="${action}">`
+ + `<label class="sh-search-q">Search <input type="search" name="q" value="${esc(q)}" placeholder="A brand, a model, a colourway" autocomplete="off"></label>`
+ + `<label>Brand <select name="brand"><option value="">Any brand</option>${brands.map((b) => `<option value="${esc(b.value)}"${values.brand === b.value ? ' selected' : ''}>${esc(b.value)} (${b.count})</option>`).join('')}</select></label>`
+ + (sizes.length ? `<label>Size <select name="size"><option value="">Any size</option>${sizes.slice(0, 80).map((s) => `<option value="${esc(s.value)}"${values.size === s.value ? ' selected' : ''}>${esc(s.value)} (${s.count})</option>`).join('')}</select></label>` : '')
+ + `<label>Condition <select name="condition"><option value="">Any</option><option value="new"${values.condition === 'new' ? ' selected' : ''}>New</option><option value="used"${values.condition === 'used' ? ' selected' : ''}>Used</option><option value="deadstock"${values.condition === 'deadstock' ? ' selected' : ''}>Deadstock</option></select></label>`
+ + ((facets.stores || []).length ? `<label>Store <select name="store"><option value="">Any store</option>${(facets.stores || []).slice(0, 40).map((s) => `<option value="${esc(s.value)}"${values.store === s.value ? ' selected' : ''}>${esc(s.value)} (${s.count})</option>`).join('')}</select></label>` : '')
+ + `<label>Sort <select name="sort"><option value="recent"${values.sort === 'recent' ? ' selected' : ''}>Recently checked</option><option value="drop"${values.sort === 'drop' ? ' selected' : ''}>Biggest drop</option><option value="price"${values.sort === 'price' ? ' selected' : ''}>Lowest all-in</option><option value="popular"${values.sort === 'popular' ? ' selected' : ''}>Most offers</option><option value="name"${values.sort === 'name' ? ' selected' : ''}>Name</option></select></label>`
+ + `<button class="sh-btn" type="submit">${icon('search')} Search</button></form>`;
+}
+
+// ── Home ────────────────────────────────────────────────────
+function home({ facets = {}, dropping = [], dropsToday = [], popular = [], curveModel = null, curve = null, sceneDrop = null, jobs = [], now = Date.now() } = {}) {
+ const brands = (facets.brands || []).slice(0, 5);
+ const priceDrops = dropsToday.filter((m) => m.drop_amount).slice(0, 8);
+ const popularRails = popular.slice(0, 8);
+ const groupedDropping = groupDrops(dropping);
+ const dropRail = groupedDropping.slice(0, 8);
+ const lastChecked = facets.checked_at ? checked(facets.checked_at, now) : null;
+ const jobsLine = jobs.filter((j) => j.finished_at).slice(0, 3).map((j) => `${j.source_name}: ${j.ok ? 'read' : 'failed'} ${dayShort(j.started_at)}`).join(' · ');
+
+ const scenes = [
+ curve && curve.points.length && curveModel ? {
+ title: 'What a size costs, in one curve',
+ text: `${curveModel.brand} ${curveModel.model} in ${curve.points.length} sizes: ${pct(curve.points[0].vs_median_percent)} against the median size, from real offers that named a size. Sizes are read only when a store states one.`,
+ html: curveBars(curve),
+ action: { href: `/shoe/${encodeURIComponent(curveModel.slug)}`, label: `See ${curveModel.model}` },
+ } : null,
+ sceneDrop ? {
+ title: 'A drop reminder, on Today',
+ text: `Remind me on ${sceneDrop.brand} ${sceneDrop.model}: the release goes on your OpenVibe.Today agenda before it happens, with the official release page as its source. Raffle entries are never automated.`,
+ html: `<div class="sh-scene-drop"><span class="sh-kicker">Today · reminder</span><b>${esc(sceneDrop.brand)} ${esc(sceneDrop.model)}${sceneDrop.colourway ? ` · ${esc(sceneDrop.colourway)}` : ''}</b>`
+ + `<span>${esc(dayLong(sceneDrop.release_at))} at ${esc(clock(sceneDrop.release_at))} UTC</span><small>${sourceLine(sceneDrop.source_name, sceneDrop.source_url)}</small><span class="sh-scene-tag">on your agenda</span></div>`,
+ action: { href: '/drops', label: 'Open the drops calendar' },
+ } : null,
+ {
+ title: 'Paste a link, keep the price',
+ text: 'A store, eBay or a Facebook Marketplace listing: paste the link and Shoes makes it a Watch. Marketplace has no public listings API and its terms forbid scraping, so Shoes never reads it itself; OpenVibe.Watch applies its own fetch rules.',
+ html: `<form class="sh-scene-track" method="post" action="/track"><label for="sh-track-url">A listing link</label><div><input id="sh-track-url" type="url" name="url" placeholder="https://www.ebay.com/itm/…" required maxlength="2000"><button class="sh-btn" type="submit">${icon('link')} Track it</button></div><small>No Marketplace scraping, ever. The watch holds the link and what Watch may fetch by its own rules.</small></form>`,
+ action: { href: '/track', label: 'Track a link' },
+ },
+ ].filter(Boolean);
+
+ // Real first (the owner, 10-31: empty sections made the rooms look unfinished): no drops today, no section.
+ const priceDropSection = priceDrops.length
+ ? rail({ id: 'sh-price-drops', title: 'The biggest price drops today', more: { href: '/shoes?sort=drop', label: 'Every drop' }, size: 's', items: priceDrops.map(modelCard) })
+ : '';
+ const body = sc.room({
+ room: 'shoes',
+ hero: {
+ title: ROOM.line,
+ lede: 'One page per shoe, every store\'s offer in one all-in price, the price history from Shoes\' own daily snapshots, and a watch that tells you when your size drops below your price.',
+ search: { action: '/shoes', name: 'q', placeholder: 'Search a shoe, a brand or a model', label: 'Search shoes', button: 'Search', hints: ['Air Max 90', 'Samba', 'Pegasus', 'New Balance 990'] },
+ chips: brands.map((b) => ({ label: b.value, href: `/shoes?brand=${encodeURIComponent(b.value)}` })),
+ },
+ proof: [
+ { value: facets.models, label: facets.models === 1 ? 'model tracked' : 'models tracked' },
+ { value: facets.offers, label: 'offers, all-in' },
+ { value: (facets.stores || []).length, label: 'stores read' },
+ { value: facets.drops, label: 'upcoming drops' },
+ ].filter((p) => p.value),
+ rails: [
+ dropRail.length ? { id: 'sh-dropping', title: 'Dropping this week', more: { href: '/drops', label: 'The calendar' }, size: 's', items: dropRail.map(dropCard) } : null,
+ popularRails.length ? { id: 'sh-popular', title: 'Popular models', more: { href: '/shoes?sort=popular', label: 'See all' }, size: 's', items: popularRails.map(modelCard) } : null,
+ ].filter(Boolean),
+ scenes,
+ explore: {
+ title: 'More on Shoes',
+ items: [
+ { title: 'Every shoe', text: 'Search by brand, model, size and condition; every price is all-in', href: '/shoes', icon: 'ov:search' },
+ { title: 'The drops calendar', text: 'Official release dates, each cited to the brand\'s or store\'s own page', href: '/drops', icon: 'ov:calendar' },
+ { title: 'Your watches', text: 'The price watches and pasted links that OpenVibe.Watch carries', href: '/watches', icon: 'ov:bell' },
+ { title: 'The API', text: 'Shoes, one shoe with its history and curve, drops, watches and open tracking', href: '/developers', icon: 'ov:code' },
+ ],
+ },
+ dev: {
+ title: 'For developers',
+ lede: 'Every model, offer, snapshot, drop and watch is open JSON. No key for the public reads; the offers are always all-in and dated.',
+ samples: [
+ { label: 'Search shoes', lang: 'bash', code: `curl "https://open.shoes/api/v1/shoes?q=air+max&size=10.5&sort=price"` },
+ { label: 'One shoe, with history and curve', lang: 'bash', code: `curl https://open.shoes/api/v1/shoes/nike-air-max-90` },
+ { label: 'The drops calendar', lang: 'bash', code: `curl https://open.shoes/api/v1/drops` },
+ ],
+ links: [{ label: 'The API', href: '/developers' }, { label: 'OpenAPI', href: '/api/openapi.json' }, { label: 'llms.txt', href: '/llms.txt' }],
+ },
+ faq: {
+ items: [
+ ['Where do the prices come from?', 'From OpenVibe.Deals\' public offers (the stores\' own pages, read by the network\'s sources) and, when a host sets a key pair, the official eBay Browse API. Each offer keeps its store, its currency, the time it was checked and a stale flag. Nothing is sponsored, and there are no affiliate links.'],
+ ['What is "all-in"?', 'The price plus the shipping when the store states shipping. When the store states no shipping, the page says the shipping is not stated; it never invents one. Every offer is per exact variant, size and condition where the source gives them.'],
+ ['Why is a spec "not known yet"?', 'Specs, fit notes, release facts and care notes are published only from cited open sources. Shoes never invents them, so a model with no cited source says "not known yet".'],
+ ['Do you scrape Facebook Marketplace?', 'No. Marketplace has no public listings API and its terms forbid scraping. A Marketplace link is tracked by pasting it: the watch holds the link, and OpenVibe.Watch applies its own fetch rules.'],
+ ['Do you enter raffles for me?', 'Never. The drops calendar shows the official release page and puts a reminder on your Today agenda. Stores\' terms forbid automated entries, so Shoes does not build them.'],
+ [lastChecked ? `When was the catalogue last read? ${lastChecked}.` : 'When was the catalogue last read?', jobsLine || 'The jobs read OpenVibe.Deals every 30 minutes and the official release pages every 6 hours. The exact last read is on every model page and in /api/v1/status.'],
+ ],
+ },
+ network: {
+ service: 'shoes',
+ title: 'Part of OpenVibe',
+ lede: 'Shoes is one room of the network. The offers come from Deals, the alerts ride Watch, and the reminders land on Today.',
+ items: [
+ { to: 'deals', what: 'The network\'s offer aggregator: stores, prices and observations', href: 'https://openvibe.deals' },
+ { to: 'watch', what: 'Watches pages, feeds and APIs, and carries the alert', href: 'https://openvibe.watch' },
+ { to: 'today', what: 'Your agenda: every drop reminder in one day', href: 'https://openvibe.today' },
+ ],
+ },
+ });
+ // The price-drop section is a real rail when there are drops and an honest note when there are none; it sits
+ // directly under the hero, before the "dropping this week" and "popular models" rails.
+ const railAt = body.indexOf('<section class="ovk-rail');
+ const sceneAt = body.indexOf('<section class="sc-room-scenes"');
+ const at = railAt >= 0 ? railAt : sceneAt;
+ return at < 0 ? `${body}${priceDropSection}` : `${body.slice(0, at)}${priceDropSection}${body.slice(at)}`;
+}
+
+// ── Search ──────────────────────────────────────────────────
+function shoesIndex({ models = [], facets = {}, query = {}, page = 1, total = 0, now = Date.now() } = {}) {
+ const perPage = 24;
+ const pages = Math.max(1, Math.ceil(total / perPage));
+ const qs = (n) => {
+ const p = new URLSearchParams();
+ for (const k of ['q', 'brand', 'size', 'condition', 'sort']) if (query[k]) p.set(k, query[k]);
+ if (n > 1) p.set('page', String(n));
+ return p.toString();
+ };
+ const body = `<div class="sh-wrap sh-page">
+<header class="sh-page-h"><p class="sh-kicker">Every shoe</p><h1>Search the catalogue</h1><p class="sh-lede">${total} model${total === 1 ? '' : 's'}${query.q ? ` for “${esc(query.q)}”` : ''}, each with every store's all-in price, the time it was checked and a stale flag.${facets.checked_at ? ` The catalogue was last read ${esc(checked(facets.checked_at, now))}.` : ''}</p></header>
+${searchForm({ q: query.q || '', facets, values: query })}
+${models.length ? `<ul class="sh-grid">${models.map((m) => `<li>${modelCard(m)}</li>`).join('')}</ul>` : `<div class="sh-empty"><h2>No shoe matches yet</h2><p>Try a brand, a model or a shorter word. The catalogue is filled by the Deals job; a model appears the first time a source names it.</p><p><a class="sh-btn" href="/shoes">Clear the filters</a></p></div>`}
+${pages > 1 ? `<nav class="sh-page-nav" aria-label="Pages"><a${page <= 1 ? ' class="is-off"' : ''} href="/shoes?${esc(qs(page - 1))}">← Previous</a><span>Page ${page} of ${pages}</span><a${page >= pages ? ' class="is-off"' : ''} href="/shoes?${esc(qs(page + 1))}">Next →</a></nav>` : ''}
+<p class="sh-fine">A model with no price says so; a price that has not been checked in ${esc(facets.stale_hours || 48)} hours is marked stale. No affiliate links, ever.</p></div>`;
+ return body;
+}
+
+// ── One shoe ────────────────────────────────────────────────
+function shoe({ model, offers = [], history = [], curve = { points: [], median: null }, facts = [], drops: releases = [], signedIn = false, now = Date.now() } = {}) {
+ if (!model) return notFound();
+ const prices = offers.map((o) => o.all_in).filter((p) => p != null);
+ const low = prices.length ? Math.min(...prices) : null;
+ const high = prices.length ? Math.max(...prices) : null;
+ const sizes = [...new Set(offers.map((o) => o.size).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
+ const colours = [...new Set([...offers.map((o) => o.colourway), ...releases.map((d) => d.colourway)].filter(Boolean))];
+ const stores = [...new Set(offers.map((o) => o.store))];
+ const staleCount = offers.filter((o) => o.stale).length;
+ const lastChecked = offers.map((o) => o.checked_at).filter(Boolean).sort().pop() || null;
+ const body = `<div class="sh-wrap sh-page">
+<nav class="crumbs" aria-label="Breadcrumbs"><a href="/">Home</a> / <a href="/shoes">Shoes</a> / ${esc(model.brand)} ${esc(model.model)}</nav>
+<header class="sh-shoe-h"><p class="sh-kicker">${esc(model.brand)}</p><h1>${esc(model.model)}</h1>
+<p class="sh-lede">${low == null ? 'No live price yet.' : `From <b>${money(low)}</b>${high != null && high !== low ? ` to ${money(high)}` : ''} all-in at ${stores.length} store${stores.length === 1 ? '' : 's'}.`} ${lastChecked ? esc(checked(lastChecked, now)) : ''}${staleCount ? ` · <span class="sh-stale">${staleCount} stale</span>` : ''}</p>
+<p>${sizes.length ? `${sizes.length} size${sizes.length === 1 ? '' : 's'} priced: ${sizes.slice(0, 12).map(esc).join(', ')}` : 'Sizes not stated by the sources yet.'}</p></header>
+${watchForm({ model, sizes, signedIn, next: `/shoe/${model.slug}` })}
+<section class="sh-sec" aria-labelledby="sh-prices-h"><h2 id="sh-prices-h">Current prices by store</h2><p class="sh-fine">Every offer is the exact listing the store published, with its shipping when stated. A price older than ${esc(48)} hours is stale; the page never hides it.</p>${offersTable(offers)}</section>
+<section class="sh-sec" aria-labelledby="sh-history-h"><h2 id="sh-history-h">Price history</h2><p class="sh-fine">One snapshot per offer per day, taken from Shoes' own checks. The line is the lowest all-in price of each day.</p>${historyChart(history)}</section>
+<section class="sh-sec" aria-labelledby="sh-curve-h"><h2 id="sh-curve-h">Size-price curve</h2>${curveTable(curve)}</section>
+<section class="sh-sec" aria-labelledby="sh-colours-h"><h2 id="sh-colours-h">Colourways</h2>${colours.length ? `<ul class="sh-chips">${colours.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>` : '<p class="sh-muted">Not known yet: no offer names a colourway.</p>'}</section>
+${releases.length ? `<section class="sh-sec" aria-labelledby="sh-releases-h"><h2 id="sh-releases-h">Release dates</h2><p class="sh-fine">From the brand's or the store's own release page, cited. Shoes never enters a raffle.</p><ul class="sh-list">${releases.map((d) => `<li><b>${esc(dayLong(d.release_at))}</b> · ${esc(d.colourway || 'colourway not stated')}${d.price != null ? ` · ${money(d.price, d.currency)}` : ''}${d.sizes.length ? ` · sizes ${d.sizes.slice(0, 12).map(esc).join(', ')}` : ''} · ${sourceLine(d.source_name, d.source_url)}${signedIn ? ` · <a href="/drops#drop-${encodeURIComponent(d.id)}">Remind me</a>` : ''}</li>`).join('')}</ul></section>` : ''}
+${factsSection(facts)}
+<p class="sh-fine">The release facts and care notes above are the source's words, cited with the page and the date Shoes read it. The stores' own pages are the only place to buy; Shoes never takes an order.</p></div>`;
+ return body;
+}
+
+// ── Drops ───────────────────────────────────────────────────
+function drops({ drops: list = [], signedIn = false, notice = null, now = Date.now() } = {}) {
+ const upcoming = groupDrops(list.filter((d) => Date.parse(d.release_at) >= now - 3600_000));
+ const byDay = new Map();
+ for (const d of upcoming) {
+ const key = String(d.release_at).slice(0, 10);
+ if (!byDay.has(key)) byDay.set(key, []);
+ byDay.get(key).push(d);
+ }
+ const next7 = upcoming.filter((d) => Date.parse(d.release_at) <= now + 7 * 86400_000);
+ const body = `<div class="sh-wrap sh-page">
+<header class="sh-page-h"><p class="sh-kicker">The drops calendar</p><h1>Dropping this week and after</h1><p class="sh-lede">Official release dates only, each read from the brand's or the store's own public release page, with the page and the time it was read. ${next7.length} release${next7.length === 1 ? '' : 's'} in the next seven days.</p></header>
+${notice ? `<div class="sh-notice" role="status">${esc(notice)}</div>` : ''}
+<p class="sh-fine"><b>Never automated entries.</b> Shoes shows the official page and can put a reminder on your Today agenda. It does not enter raffles or draws; stores' terms forbid bots. Reservations only where a store offers them officially.</p>
+${upcoming.length ? [...byDay.entries()].map(([day, rows]) => `<section class="sh-drop-day" aria-labelledby="sh-day-${esc(day)}"><h2 id="sh-day-${esc(day)}">${esc(dayLong(rows[0].release_at))}</h2><ul class="sh-drops">${rows.map((d) => `<li class="sh-drop" id="drop-${esc(d.id)}"><div><b>${esc(d.brand)} ${esc(d.model)}</b>${d.colourway ? ` <span class="sh-colour">${esc(d.colourway)}</span>` : ''}<span class="sh-drop-meta">${esc(clock(d.release_at))} UTC · ${d.price != null ? money(d.price, d.currency) : 'price not stated'}${d.sizes.length ? ` · ${d.sizes.length} sizes` : ''}</span><span class="sh-drop-src">${sourceLine(d.source_name, d.source_url)} · read ${esc(dayShort(d.fetched_at))}</span></div>${signedIn ? `<form method="post" action="/drops/${encodeURIComponent(d.id)}/remind"><button class="sh-btn sh-btn-quiet" type="submit">${icon('bell')} Remind me</button></form>` : `<a class="sh-btn sh-btn-quiet" href="/auth/login?next=${encodeURIComponent('/drops')}">${icon('bell')} Sign in to be reminded</a>`}</li>`).join('')}</ul></section>`).join('') : '<div class="sh-empty"><h2>No release is listed right now</h2><p>The release pages answered but published no dated release in the window. The calendar fills on the next read.</p></div>'}
+</div>`;
+ return body;
+}
+
+// ── Watches ─────────────────────────────────────────────────
+function watches({ watches: list = [], signedIn = false, notice = null } = {}) {
+ if (!signedIn) {
+ return `<div class="sh-wrap sh-page"><header class="sh-page-h"><p class="sh-kicker">Your watches</p><h1>Sign in to see your watches</h1><p class="sh-lede">A watch belongs to a person. Sign in with OpenVibe and your price watches and pasted links are here.</p><p><a class="sh-btn" href="/auth/login?next=${encodeURIComponent('/watches')}">Sign in with OpenVibe</a></p></header></div>`;
+ }
+ const body = `<div class="sh-wrap sh-page"><header class="sh-page-h"><p class="sh-kicker">Your watches</p><h1>Watches</h1><p class="sh-lede">Every watch is carried by OpenVibe.Watch. A price watch tells you when the all-in price for your size drops below your number; a pasted link keeps the listing and lets Watch apply its own fetch rules.</p></header>
+${notice ? `<div class="sh-notice" role="status">${esc(notice)}</div>` : ''}
+${list.length ? `<div class="sh-table-wrap"><table class="sh-table"><caption>Your watches, newest first</caption><thead><tr><th scope="col">Kind</th><th scope="col">What</th><th scope="col">Size</th><th scope="col">Below</th><th scope="col">Status</th><th scope="col">Created</th></tr></thead><tbody>${list.map((w) => `<tr><td>${esc(w.kind)}</td><td>${w.slug ? link(`/shoe/${encodeURIComponent(w.slug)}`, w.slug) : w.source_url ? `<a href="${esc(w.source_url)}" rel="noopener nofollow">${esc(w.source_url)}</a>` : '—'}${w.note ? `<br><small>${esc(w.note)}</small>` : ''}</td><td>${w.size ? esc(w.size) : 'any'}</td><td>${w.threshold != null ? money(w.threshold, w.currency) : '—'}</td><td>${esc(w.status)}${w.remote_id ? `<br><small>Watch ${esc(w.remote_id)}</small>` : ''}</td><td>${esc(dayShort(w.created_at))}</td></tr>`).join('')}</tbody></table></div>` : '<div class="sh-empty"><h2>No watch yet</h2><p>Open a shoe and set the price for your size, or paste a listing link on the home page.</p><p><a class="sh-btn" href="/shoes">Find a shoe</a></p></div>'}
+</div>`;
+ return body;
+}
+
+// ── Developers ──────────────────────────────────────────────
+function developers({ baseUrl = 'https://open.shoes' } = {}) {
+ const api = String(baseUrl).replace(/\/+$/, '');
+ const rows = [
+ ['GET', '/api/v1/shoes?q&brand&size&condition&store&sort&page', 'Search the catalogue. `sort`: recent, drop, price, price_desc, popular, name. The answer carries the facets.'],
+ ['GET', '/api/v1/shoes/:slug', 'One model: its offers, its daily price history, the size-price curve and the cited facts.'],
+ ['GET', '/api/v1/shoes/:slug/price?size=&condition=', 'The lowest all-in price for a model (and size), for a Watch to poll.'],
+ ['GET', '/api/v1/drops?from&to', 'The official release dates, soonest first, each with its source page and read time.'],
+ ['GET', '/api/v1/status', 'The last read of each source: when, how many rows, whether it answered.'],
+ ['POST', '/api/v1/me/watches', 'Session only: { slug, size?, condition?, threshold, currency? } → a price watch in OpenVibe.Watch.'],
+ ['GET', '/api/v1/me/watches', 'Session only: the person\'s watches.'],
+ ['POST', '/api/v1/me/track', 'Session only: { url } → a Watch for a pasted listing, through Watch\'s own fetch rules.'],
+ ['GET', '/api/openapi.json', 'This API as an OpenAPI 3.1 document.'],
+ ];
+ const body = `<div class="sh-wrap sh-page">
+<header class="sh-page-h"><p class="sh-kicker">Developers</p><h1>The Shoes API</h1><p class="sh-lede">Every model, offer, snapshot and drop is open JSON. The public reads take no key; a write is a signed-in person's own. There are no affiliate links and no Marketplace scraping.</p></header>
+<section class="sh-sec" aria-labelledby="sh-api-h"><h2 id="sh-api-h">Routes</h2><div class="sh-table-wrap"><table class="sh-table"><thead><tr><th scope="col">Method</th><th scope="col">Route</th><th scope="col">What it does</th></tr></thead><tbody>${rows.map(([m, r, d]) => `<tr><td><code>${m}</code></td><td><code>${esc(r)}</code></td><td>${esc(d)}</td></tr>`).join('')}</tbody></table></div>
+<p><a class="sh-btn" href="/api/openapi.json">OpenAPI 3.1</a> <a class="sh-btn sh-btn-quiet" href="/api/v1/shoes?q=air">Try a search</a></p></section>
+<section class="sh-sec" aria-labelledby="sh-src-h"><h2 id="sh-src-h">Where the data comes from</h2><ul class="sh-list"><li><b>OpenVibe.Deals</b> is the network's offer aggregator: Shoes reads its public newest-offer pages and keeps the footwear, then reads each product's public record for its brand.</li><li><b>eBay</b> is read through the official Browse API, and only when the host sets a developer key pair.</li><li><b>Release pages</b> are the brand's or the store's own public release calendar (Nike SNKRS by default), read by a pure parser. A release with no date is not imported.</li><li><b>No scraping</b> of Facebook Marketplace; a Marketplace link is tracked by the person pasting it and Watch applying its own rules.</li></ul></section>
+<section class="sh-sec" aria-labelledby="sh-ex-h"><h2 id="sh-ex-h">Examples</h2><pre class="sh-code"><code>curl "${api}/api/v1/shoes?q=air+max&size=10.5&sort=price"</code></pre>
+<pre class="sh-code"><code>curl "${api}/api/v1/shoes/nike-air-max-90"</code></pre>
+<pre class="sh-code"><code>curl "${api}/api/v1/drops"</code></pre></section>
+<p class="sh-fine">Errors are <code>application/problem+json</code> with a <code>code</code>. The public reads are rate-limited per address; a signed-in write also needs this site's origin.</p></div>`;
+ return body;
+}
+
+function track({ signedIn = false, notice = null, url = '' } = {}) {
+ return `<div class="sh-wrap sh-page"><header class="sh-page-h"><p class="sh-kicker">Paste a link</p><h1>Track a listing</h1><p class="sh-lede">A store, eBay or a Facebook Marketplace listing: paste the link and it becomes a Watch. OpenVibe.Watch applies its own fetch rules; Shoes never scrapes Marketplace, and never reads a page itself.</p></header>
+${notice ? `<div class="sh-notice" role="status">${esc(notice)}</div>` : ''}
+${signedIn ? `<form class="sh-track-form" method="post" action="/track"><label for="sh-track-url">The listing link</label><input id="sh-track-url" type="url" name="url" value="${esc(url)}" placeholder="https://www.ebay.com/itm/…" required maxlength="2000"><button class="sh-btn" type="submit">${icon('link')} Track it</button></form>` : `<p><a class="sh-btn" href="/auth/login?next=${encodeURIComponent('/track')}">Sign in with OpenVibe</a> and paste the link.</p>`}
+<section class="sh-sec"><h2>What Shoes will and will not do</h2><ul class="sh-list"><li><b>Will</b> keep the link as a Watch with OpenVibe.Watch, which fetches it under its own rules and tells you when it changes.</li><li><b>Will not</b> scrape Facebook Marketplace, or any site whose terms forbid it.</li><li><b>Will not</b> buy, bid, reserve or enter anything for you.</li></ul></section></div>`;
+}
+
+function signIn({ title = 'Sign in', next = '/' } = {}) {
+ return `<div class="sh-wrap sh-page"><header class="sh-page-h"><p class="sh-kicker">OpenVibe account</p><h1>${esc(title)}</h1><p class="sh-lede">This page belongs to a person. Sign in with OpenVibe; you come back to where you were.</p><p><a class="sh-btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a></p></header></div>`;
+}
+
+function notFound() {
+ return `<div class="sh-wrap sh-page"><header class="sh-page-h"><p class="sh-kicker">404</p><h1>No such page</h1><p class="sh-lede">That shoe, drop or page is not here. The catalogue is at <a href="/shoes">/shoes</a>, the drops at <a href="/drops">/drops</a>.</p><p><a class="sh-btn" href="/">Home</a></p></header></div>`;
+}
+
+module.exports = { home, shoesIndex, shoe, drops, watches, track, developers, signIn, notFound, modelCard, dropCard, groupDrops, money, historyChart, curveBars, offersTable };
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+/**
+ * ADR-033: Shoes' part of an account export (a person's watches) and of a deletion, applied through
+ * /internal/events with a stand-in Network. A deletion erases the person's watches and leaves other people's and
+ * the network's catalogue alone. A redelivery changes nothing; a bad signature and a proxied request are refused.
+ */
+const assert = require('assert');
+const http = require('http');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+
+const A = ids.newId('user');
+const B = ids.newId('user');
+const EXP = `exp_${ids.ulid()}`;
+const DEL = `del_${ids.ulid()}`;
+const SECRET = `fixture_${'account'.repeat(6)}`;
+const WRONG = `fixture_${'mismatch'.repeat(6)}`;
+
+const exportEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXP, subject: A } };
+const deleteEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DEL, subject: A, aliases: [] } };
+const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });
+
+async function startNetworkStub() {
+ const calls = [];
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks);
+ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_shoes', token_type: 'Bearer', expires_in: 300 });
+ calls.push({ url: req.url, auth: req.headers.authorization, body: JSON.parse(raw.toString() || 'null') });
+ return json(201, {});
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };
+}
+
+(async () => {
+ const stub = await startNetworkStub();
+ const sender = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'shoes', clientSecret: 'shoes-secret' });
+ const t = await boot({ env: { SHOES_EVENTS_SECRET: SECRET }, accountSend: sender });
+ try {
+ const db = t.ctx.s.db;
+ const watchA = `shw_${ids.ulid()}`;
+ const watchB = `shw_${ids.ulid()}`;
+ await db.exec(
+ `INSERT INTO shoe_watches (id, subject, kind, slug, size, threshold_amount, currency, status)
+ VALUES ($1, $2, 'price', 'nike-air-max-90', '10.5', 90, 'USD', 'active'), ($3, $4, 'link', NULL, NULL, NULL, 'USD', 'active')`,
+ [watchA, A, watchB, B],
+ );
+
+ await check('an export carries only A\'s watches', async () => {
+ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.json().outcome, 'exported');
+ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);
+ assert.ok(part, 'the part was pushed to Network');
+ assert.strictEqual(part.auth, 'Bearer tok_shoes');
+ assert.strictEqual(part.body.subject, A);
+ assert.deepStrictEqual(part.body.files.map((f) => f.name), ['watches.json']);
+ const watches = part.body.files[0].content;
+ assert.deepStrictEqual(watches.map((w) => w.id), [watchA]);
+ });
+
+ await check('a deletion erases A\'s watches and confirms with counts; B stays', async () => {
+ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.json().outcome, 'erased');
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM shoe_watches WHERE subject = $1', [A])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM shoe_watches WHERE subject = $1', [B])), 1);
+ const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
+ assert.ok(confirmation, 'the confirmation was sent');
+ assert.ok(confirmation.body.erased.shoe_watches >= 1);
+ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
+ });
+
+ await check('a redelivery changes nothing', async () => {
+ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.json().outcome, 'unchanged');
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM shoe_watches WHERE subject = $1', [A])), 0);
+ });
+
+ await check('the internal route refuses a bad signature (401) and a forwarded request (403)', async () => {
+ const bad = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), WRONG) } });
+ assert.strictEqual(bad.status, 401);
+ const forwarded = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
+ assert.strictEqual(forwarded.status, 403);
+ });
+ } finally {
+ await t.close();
+ await stub.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,173 @@
@@ -0,0 +1,173 @@
+'use strict';
+
+/**
+ * Every /api/v1 route on a booted app: the public search and one shoe, the price a Watch polls, the drops, the
+ * signed-in price watch and pasted link (through the Watch stand-in), the cross-site refusal, the source status
+ * and the OpenAPI document.
+ */
+const { boot, check, done } = require('./helpers/boot');
+const { assert, equal } = require('./helpers/check');
+
+(async () => {
+ const t = await boot();
+ try {
+ await t.ctx.jobs.offers.runOnce({ pages: 2 });
+ await t.ctx.jobs.drops.runOnce();
+ const user = t.network.addUser('ada');
+
+ await check('GET /api/v1/shoes: the catalogue and its facets', async () => {
+ const res = await t.get('/api/v1/shoes');
+ equal(res.status, 200);
+ const b = res.json();
+ equal(b.shoes.length, 4);
+ equal(b.total, 4);
+ equal(b.facets.models, 4);
+ equal(b.facets.offers, 4);
+ assert(b.facets.brands.some((x) => x.value === 'Nike'), 'brand facet');
+ assert(b.shoes.every((s) => s.offer_count === 1), 'one offer each');
+ });
+
+ await check('GET /api/v1/shoes?brand&size&sort: the filters', async () => {
+ const nike = (await t.get('/api/v1/shoes?brand=Nike')).json();
+ equal(nike.shoes.length, 1);
+ equal(nike.shoes[0].slug, 'nike-air-max-90');
+ const sized = (await t.get('/api/v1/shoes?size=10.5')).json();
+ equal(sized.shoes.length, 1);
+ equal(sized.shoes[0].slug, 'adidas-samba-og');
+ const sorted = (await t.get('/api/v1/shoes?sort=price')).json();
+ equal(sorted.shoes[0].slug, 'nike-air-max-90');
+ });
+
+ await check('GET /api/v1/shoes/:slug: offers, history, curve and facts', async () => {
+ const res = await t.get('/api/v1/shoes/nike-air-max-90');
+ equal(res.status, 200);
+ const b = res.json();
+ equal(b.shoe.brand, 'Nike');
+ equal(b.offers.length, 1);
+ equal(b.offers[0].all_in, 89.95);
+ equal(b.history.length, 1);
+ equal(b.history[0].day, '2026-10-31');
+ equal(b.curve.points.length, 0, 'no size named on this offer');
+ equal(b.facts.length, 0);
+ });
+
+ await check('GET /api/v1/shoes/:slug/price: the Watch feed', async () => {
+ const res = await t.get('/api/v1/shoes/nike-air-max-90/price');
+ equal(res.status, 200);
+ const b = res.json();
+ equal(b.price, 89.95);
+ equal(b.offers, 1);
+ assert(b.checked_at, 'the check time');
+ equal((await t.get('/api/v1/shoes/no-such-shoe/price')).status, 404);
+ equal((await t.get('/api/v1/shoes/NOT_A_SLUG')).status, 404);
+ });
+
+ await check('GET /api/v1/drops: the official releases', async () => {
+ const res = await t.get('/api/v1/drops');
+ equal(res.status, 200);
+ const b = res.json();
+ equal(b.drops.length, 3);
+ assert(b.drops.every((d) => d.source_url), 'every drop cites its source');
+ const one = await t.get(`/api/v1/drops/${encodeURIComponent(b.drops[0].id)}`);
+ equal(one.status, 200);
+ equal(one.json().model, b.drops[0].model);
+ equal((await t.get('/api/v1/drops/not-a-drop')).status, 404);
+ equal((await t.get('/api/v1/drops?from=not-a-date')).status, 400);
+ });
+
+ await check('POST /api/v1/me/watches: a session is required', async () => {
+ const res = await t.get('/api/v1/me/watches', { json: { slug: 'nike-air-max-90', threshold: 90 }, method: 'POST' });
+ equal(res.status, 401);
+ equal(res.json().code, 'token.required');
+ });
+
+ await check('POST /api/v1/me/watches: a price watch through OpenVibe.Watch', async () => {
+ const res = await t.get('/api/v1/me/watches', { as: user, json: { slug: 'nike-air-max-90', size: '10.5', condition: 'new', threshold: 90 } });
+ equal(res.status, 201);
+ const b = res.json();
+ equal(b.watch.kind, 'price');
+ equal(b.watch.status, 'active');
+ assert(b.watch.remote_id, 'a remote Watch id');
+ assert(b.remote, 'the remote watch');
+ const sent = t.upstreams.state.watchRequests[0];
+ equal(sent.body.source.kind, 'api');
+ assert(sent.body.source.url.includes('/api/v1/shoes/nike-air-max-90/price'), 'the feed URL');
+ equal(sent.body.condition.op, 'lt');
+ equal(sent.body.condition.value, 90);
+ equal(sent.headers['x-ov-subject'], user.subject);
+ const list = (await t.get('/api/v1/me/watches', { as: user })).json();
+ equal(list.watches.length, 1);
+ });
+
+ await check('POST /shoe/:slug/watch: the page form creates the same price watch', async () => {
+ const before = t.upstreams.state.watchRequests.length;
+ const res = await t.get('/shoe/nike-air-max-90/watch', { as: user, form: { threshold: '75', size: '10.5', condition: 'new' } });
+ equal(res.status, 302);
+ assert(res.headers.get('location').startsWith('/watches?notice='), 'to the watches with a note');
+ equal(t.upstreams.state.watchRequests.length, before + 1);
+ const sent = t.upstreams.state.watchRequests[before];
+ equal(sent.body.condition.value, 75);
+ equal(sent.body.source.kind, 'api');
+ });
+
+ await check('POST /api/v1/me/track: a pasted link becomes a Watch', async () => {
+ const res = await t.get('/api/v1/me/track', { as: user, json: { url: 'https://www.ebay.com/itm/123' } });
+ equal(res.status, 201);
+ const b = res.json();
+ equal(b.watch.kind, 'link');
+ equal(b.watch.source_url, 'https://www.ebay.com/itm/123');
+ const sent = t.upstreams.state.watchRequests[t.upstreams.state.watchRequests.length - 1];
+ equal(sent.body.source.kind, 'http');
+ equal(sent.body.condition.op, 'changed');
+ equal(sent.body.extraction.kind, 'text');
+ equal((await t.get('/api/v1/me/track', { as: user, json: { url: '' } })).status, 400);
+ equal((await t.get('/api/v1/me/track', { as: user, json: { url: 'ftp://example.com/x' } })).status, 400);
+ });
+
+ await check('a signed-in write from another site is refused', async () => {
+ const res = await t.get('/api/v1/me/watches', { as: user, origin: 'https://evil.example', json: { slug: 'nike-air-max-90', threshold: 90 } });
+ equal(res.status, 403);
+ equal(res.json().code, 'shoes.cross_site');
+ });
+
+ await check('GET /api/health: the machine endpoint', async () => {
+ const res = await t.get('/api/health');
+ equal(res.status, 200);
+ equal(res.json().service, 'openvibe-shoes');
+ equal(res.json().name, 'OpenVibe.Shoes');
+ });
+
+ await check('GET /api/v1/status: the jobs, counts and what is configured', async () => {
+ const b = (await t.get('/api/v1/status')).json();
+ equal(b.counts.models, 4);
+ equal(b.sources.watch, 'configured');
+ equal(b.sources.ebay, 'configured');
+ assert(b.jobs.some((j) => j.job === 'offers'), 'the offers job is listed');
+ assert(b.jobs.some((j) => j.job === 'drops'), 'the drops job is listed');
+ });
+
+ await check('GET /api/openapi.json: a valid OpenAPI 3.1 document with every route', async () => {
+ const res = await t.get('/api/openapi.json');
+ equal(res.status, 200);
+ const b = res.json();
+ equal(b.openapi, '3.1.0');
+ assert(b.paths['/api/v1/shoes'], 'shoes');
+ assert(b.paths['/api/v1/shoes/{slug}'], 'one shoe');
+ assert(b.paths['/api/v1/shoes/{slug}/price'], 'price');
+ assert(b.paths['/api/v1/drops'], 'drops');
+ assert(b.paths['/api/v1/me/watches'], 'watches');
+ assert(b.paths['/api/v1/me/track'], 'track');
+ equal((await t.get('/api/v1/openapi.json')).status, 200);
+ });
+
+ await check('an unknown API route is problem+json', async () => {
+ const res = await t.get('/api/v1/nope');
+ equal(res.status, 404);
+ assert(res.headers.get('content-type').includes('application/problem+json'), 'problem+json');
+ equal(res.json().code, 'route.not_found');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,195 @@
@@ -0,0 +1,195 @@
+'use strict';
+
+/**
+ * The catalogue (server/catalogue.js) on a real migrated PGlite database: one model per brand + model, one snapshot
+ * per offer per day, all-in prices, the list-price and day-over-day drop, the size curve, drops, cited facts, a
+ * person's watches and the job runs.
+ */
+const { check, done, assert, equal } = require('./helpers/check');
+const { testDb } = require('./helpers/db');
+const { createStore } = require('../server/db');
+const { createCatalogue } = require('../server/catalogue');
+
+(async () => {
+ const test = await testDb();
+ let clock = Date.parse('2026-10-31T22:00:00Z');
+ const s = createStore(test.db, { now: () => clock });
+ const c = createCatalogue(s, { staleHours: 48 });
+ const user = 'usr_01J00000000000000000000AA';
+
+ try {
+ await check('upsertOffer creates a model from the product and the source brand', async () => {
+ const a = await c.upsertOffer({
+ source: 'deals', sourceId: 'deal-1', title: 'Nike Air Max 90 Running Shoe - White',
+ modelName: 'Air Max 90', brand: 'Nike', url: 'https://www.zappos.com/p/1', store: 'zappos.com', storeName: 'Zappos',
+ price: '100.00', listPrice: '130.00', shipping: '5.99', condition: 'New', size: '10.5', colourway: 'White',
+ checkedAt: '2026-10-31T20:00:00Z', raw: { id: 'deal-1' },
+ });
+ assert(a, 'offer written');
+ const model = await c.modelBySlug('nike-air-max-90');
+ assert(model, 'model slug is nike-air-max-90');
+ equal(model.brand, 'Nike');
+ equal(model.model, 'Air Max 90');
+ const offers = await c.offersOf(model.id);
+ equal(offers.length, 1);
+ equal(offers[0].all_in, 105.99);
+ equal(offers[0].drop_from_list, 24.01);
+ equal(offers[0].size, '10.5');
+ equal(offers[0].colourway, 'White');
+ equal(offers[0].stale, false);
+ });
+
+ await check('a second offer on the same model joins it; a duplicate source id updates', async () => {
+ const b = await c.upsertOffer({
+ source: 'deals', sourceId: 'deal-2', title: 'Nike Air Max 90 Running Shoe',
+ modelName: 'Air Max 90', brand: 'Nike', url: 'https://www.footlocker.com/p/2', store: 'footlocker.com',
+ price: '95.00', shipping: null, condition: 'new', size: '9', checkedAt: '2026-10-31T20:30:00Z',
+ });
+ assert(b, 'second offer');
+ const model = await c.modelBySlug('nike-air-max-90');
+ equal((await c.offersOf(model.id)).length, 2);
+ await c.upsertOffer({
+ source: 'deals', sourceId: 'deal-2', title: 'Nike Air Max 90 Running Shoe',
+ modelName: 'Air Max 90', brand: 'Nike', url: 'https://www.footlocker.com/p/2', store: 'footlocker.com',
+ price: '90.00', shipping: null, condition: 'new', size: '9', checkedAt: '2026-10-31T21:30:00Z',
+ });
+ const offers = await c.offersOf(model.id);
+ equal(offers.length, 2, 'still two offers');
+ equal(offers.find((o) => o.source === 'deals' && o.price === 90).all_in, 90);
+ });
+
+ await check('history: one snapshot per offer per day, the first check wins', async () => {
+ const model = await c.modelBySlug('nike-air-max-90');
+ const history = await c.history(model.id);
+ equal(history.length, 1);
+ equal(history[0].day, '2026-10-31');
+ equal(history[0].offers, 2);
+ equal(history[0].price, 95, 'the lowest offer of the day');
+ });
+
+ await check('a next-day check adds a point and a day-over-day drop', async () => {
+ const model = await c.modelBySlug('nike-air-max-90');
+ await c.upsertOffer({
+ source: 'deals', sourceId: 'deal-2', title: 'Nike Air Max 90 Running Shoe',
+ modelName: 'Air Max 90', brand: 'Nike', url: 'https://www.footlocker.com/p/2', store: 'footlocker.com',
+ price: '70.00', shipping: null, condition: 'new', size: '9', checkedAt: '2026-11-01T09:00:00Z',
+ });
+ const history = await c.history(model.id);
+ equal(history.length, 2);
+ const listed = await c.listShoes({ brand: 'Nike', sort: 'drop' });
+ assert(listed[0].drop_amount >= 24, 'the list-price drop is still the biggest');
+ equal(listed[0].offer_count, 2);
+ equal(listed[0].store_count, 2);
+ });
+
+ await check('curve: the lowest all-in per size, against the median', async () => {
+ const model = await c.modelBySlug('nike-air-max-90');
+ const curve = await c.curve(model.id);
+ equal(curve.points.length, 2);
+ equal(curve.points[0].size, '9');
+ equal(curve.points[1].size, '10.5');
+ assert(curve.median != null, 'a median size price');
+ assert(curve.points.every((p) => p.vs_median_percent != null), 'every point says how it compares');
+ });
+
+ await check('listShoes: paging, filters and facets', async () => {
+ const shoes = await c.listShoes({ limit: 1, offset: 0 });
+ equal(shoes.length, 1);
+ const total = await c.countShoes({ brand: 'Nike' });
+ equal(total, 1);
+ equal((await c.countShoes({ size: '10.5' })), 1);
+ equal((await c.countShoes({ condition: 'used' })), 0);
+ const facets = await c.facets();
+ equal(facets.models, 1);
+ equal(facets.offers, 2);
+ assert(facets.brands.some((b) => b.value === 'Nike'), 'brand facet');
+ assert(facets.sizes.some((s) => s.value === '10.5'), 'size facet');
+ });
+
+ await check('priceFor: the lowest all-in for a size', async () => {
+ const model = await c.modelBySlug('nike-air-max-90');
+ const any = await c.priceFor(model.id);
+ equal(any.price, 70);
+ const ten = await c.priceFor(model.id, { size: '10.5' });
+ equal(ten.price, 105.99);
+ const missing = await c.priceFor(model.id, { size: '4' });
+ equal(missing.price, null);
+ });
+
+ await check('drops: upsert, list, one by id, stable slug', async () => {
+ const d1 = await c.upsertDrop({
+ brand: 'Nike', model: 'Air Max 90', colourway: 'Medium Olive', releaseAt: '2026-11-04T15:00:00Z',
+ price: 130, sizes: ['9', '10.5'], sourceName: 'Nike SNKRS', sourceUrl: 'https://www.nike.com/launch',
+ sourceKey: 'air-max-90|2026-11-04', fetchedAt: '2026-10-31T20:00:00Z',
+ });
+ assert(d1, 'drop written');
+ const again = await c.upsertDrop({
+ brand: 'Nike', model: 'Air Max 90', colourway: 'Medium Olive', releaseAt: '2026-11-04T15:00:00Z',
+ price: 130, sizes: ['9', '10.5'], sourceName: 'Nike SNKRS', sourceUrl: 'https://www.nike.com/launch',
+ sourceKey: 'air-max-90|2026-11-04', fetchedAt: '2026-10-31T21:00:00Z',
+ });
+ equal(again.id, d1.id, 'the source key updates the same drop');
+ const list = await c.listDrops({ from: '2026-11-01T00:00:00Z' });
+ equal(list.length, 1);
+ equal(list[0].sizes, ['9', '10.5']);
+ equal((await c.dropById(d1.id)).model, 'Air Max 90');
+ equal(await c.upsertDrop({ brand: 'Nike', model: 'No URL', releaseAt: '2026-11-04T15:00:00Z', sourceName: 'x', sourceKey: 'k' }), null);
+ });
+
+ await check('facts: only cited, only the four kinds', async () => {
+ const model = await c.modelBySlug('nike-air-max-90');
+ const fact = await c.addFact({ modelId: model.id, kind: 'specs', label: 'Weight', value: '12.1 oz', sourceName: 'Nike', sourceUrl: 'https://www.nike.com/air-max-90', fetchedAt: '2026-10-31T20:00:00Z' });
+ assert(fact, 'fact written');
+ equal(await c.addFact({ modelId: model.id, kind: 'guess', label: 'x', value: 'y', sourceName: 'z', sourceUrl: 'https://example.com' }), null);
+ equal(await c.addFact({ modelId: model.id, kind: 'fit', label: 'x', value: 'y', sourceName: 'z', sourceUrl: 'not a url' }), null);
+ const facts = await c.factsOf(model.id);
+ equal(facts.length, 1);
+ equal(facts[0].source_url, 'https://www.nike.com/air-max-90');
+ });
+
+ await check('watches: a person’s own, newest first', async () => {
+ clock += 1000;
+ await c.createWatch({ subject: user, kind: 'price', modelId: (await c.modelBySlug('nike-air-max-90')).id, slug: 'nike-air-max-90', size: '10.5', threshold: 90, remoteId: 'wch_1', status: 'active' });
+ clock += 1000;
+ await c.createWatch({ subject: user, kind: 'link', sourceUrl: 'https://www.ebay.com/itm/1', status: 'not_connected', note: 'no token' });
+ const list = await c.listWatches(user);
+ equal(list.length, 2);
+ equal(list[0].kind, 'link');
+ equal(list[1].threshold, 90);
+ equal((await c.listWatches('usr_01J00000000000000000000BB')).length, 0);
+ });
+
+ await check('job runs: start, finish and the last run per job', async () => {
+ const id = await c.startJobRun({ job: 'offers', sourceName: 'OpenVibe.Deals', sourceUrl: 'https://openvibe.deals/api/v1/offers' });
+ await c.finishJobRun(id, { ok: true, counts: { offers: 2 } });
+ const last = await c.lastJobRuns();
+ equal(last.length, 1);
+ equal(last[0].job, 'offers');
+ equal(last[0].ok, true);
+ equal(last[0].counts.offers, 2);
+ });
+
+ await check('stale is computed from the check time, not frozen at import', async () => {
+ const model = await c.modelBySlug('nike-air-max-90');
+ await s.db.exec("UPDATE shoe_offers SET stale = false, checked_at = '2026-10-25T12:00:00Z' WHERE model_id = $1", [model.id]);
+ const offers = await c.offersOf(model.id);
+ assert(offers.length && offers.every((o) => o.stale), 'every old offer reads stale');
+ const listed = await c.listShoes({ brand: 'Nike' });
+ equal(listed[0].stale_count, 2);
+ equal((await c.facets()).stale_offers, 2);
+ });
+
+ await check('counts: the rows the seed report prints', async () => {
+ const counts = await c.counts();
+ equal(counts.models, 1);
+ equal(counts.offers, 2);
+ equal(counts.snapshots, 3);
+ equal(counts.drops, 1);
+ equal(counts.watches, 2);
+ equal(counts.facts, 1);
+ });
+ } finally {
+ await test.close();
+ }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+'use strict';
+
+/**
+ * The release-page parser (server/domain/drops.js). It never invents a date: a release with no date or no model is
+ * not returned, a launch payload's colourway is the quoted colour, and the size grid is the page's own.
+ */
+const { check, done, assert, equal } = require('./helpers/check');
+const drops = require('../server/domain/drops');
+const { LAUNCH_HTML, RSS } = require('./helpers/upstreams');
+
+(async () => {
+ await check('parseLaunchCalendar: dated releases with model, colourway, price and sizes', () => {
+ const out = drops.parseLaunchCalendar(LAUNCH_HTML, { sourceName: 'Nike SNKRS', sourceUrl: 'https://www.nike.com/launch', fetchedAt: '2026-10-31T22:00:00Z' });
+ equal(out.length, 2);
+ equal(out[0].brand, 'Nike');
+ equal(out[0].model, 'Air Max 90');
+ equal(out[0].colourway, 'Medium Olive');
+ equal(out[0].price, 130);
+ equal(out[0].releaseAt, '2026-11-04T15:00:00.000Z');
+ equal(out[0].sizes, ['10.5', '9']);
+ equal(out[0].sourceName, 'Nike SNKRS');
+ equal(out[0].fetchedAt, '2026-10-31T22:00:00Z');
+ equal(out[1].brand, 'Jordan');
+ equal(out[1].model, '6 Retro');
+ });
+
+ await check('parseLaunchCalendar: no commerceStartDate, no release', () => {
+ equal(drops.parseLaunchCalendar('<html>nothing here</html>', { sourceName: 'x', sourceUrl: 'https://example.com' }), []);
+ equal(drops.parseLaunchCalendar('{"commerceStartDate":"not a date","title":"Air Max 90"}', { sourceName: 'x', sourceUrl: 'https://example.com' }), []);
+ equal(drops.parseLaunchCalendar('{"commerceStartDate":"2026-11-04T15:00:00Z"}', { sourceName: 'x', sourceUrl: 'https://example.com' }), []);
+ });
+
+ await check('parseRss: dated items only, with their link as the source', () => {
+ const out = drops.parseRss(RSS, { sourceName: 'Example releases', sourceUrl: 'https://example.com/feed' });
+ equal(out.length, 1);
+ equal(out[0].brand, 'Puma');
+ equal(out[0].model, 'Suede Classic');
+ equal(out[0].sourceUrl, 'https://example.com/releases/puma-suede');
+ equal(out[0].releaseAt, '2026-11-11T10:00:00.000Z');
+ });
+
+ await check('field: reads a quoted, numeric, boolean or null field', () => {
+ equal(drops.field('{"a":"x\\"y","b":12.5,"c":true,"d":null}', 'a'), 'x"y');
+ equal(drops.field('{"b":12.5}', 'b'), 12.5);
+ equal(drops.field('{"c":true}', 'c'), true);
+ equal(drops.field('{"d":null}', 'd'), null);
+ equal(drops.field('{}', 'e'), null);
+ });
+
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+'use strict';
+// Many requests per test file: the per-address limits would answer 429 part-way.
+process.env.SHOES_RATE_LIMIT_PER_MIN = process.env.SHOES_RATE_LIMIT_PER_MIN || '100000';
+process.env.SHOES_API_RATE_LIMIT_PER_MIN = process.env.SHOES_API_RATE_LIMIT_PER_MIN || '100000';
+process.env.SHOES_LIMITS_MINUTE = process.env.SHOES_LIMITS_MINUTE || '100000';
+process.env.SHOES_LIMITS_HOUR = process.env.SHOES_LIMITS_HOUR || '100000';
+/**
+ * Boots OpenVibe.Shoes on a temp database with a mock Network (test/helpers/mocks.js) and in-process public
+ * sources and sibling services (test/helpers/upstreams.js), captures log lines, and returns a small HTTP client.
+ * Every test file gets its own.
+ *
+ * const t = await boot();
+ * t.get(path, { as: user, bearer, json, form, headers, method, cookie, origin })
+ * t.signIn(user) → cookie header value (shoes_at = a Network user token, as after /auth/callback)
+ * t.logs(), t.dbDump()
+ *
+ * `urlCheck` is a no-op by default so a pasted link does not need DNS in a test; production uses
+ * openvibe-shared/egress. Pass `urlCheck` to test the refusal.
+ */
+const http = require('http');
+const { startNetwork } = require('./mocks');
+const { startUpstreams } = require('./upstreams');
+
+const captured = [];
+for (const m of ['log', 'info', 'warn', 'error']) {
+ const orig = console[m].bind(console);
+ console[m] = (...a) => { captured.push(a.map(String).join(' ')); if (process.env.VERBOSE) orig(...a); };
+}
+
+async function boot(opts = {}) {
+ const network = await startNetwork(opts.network || {});
+ const upstreams = await startUpstreams(opts.upstreams || {});
+ const env = {
+ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://open.shoes', TRUST_PROXY: '1',
+ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
+ OV_OAUTH_CLIENT_ID: 'shoes', OV_OAUTH_CLIENT_SECRET: 'shoes-secret', COOKIE_SECURE: 'false',
+ SHOES_DRILL: '1',
+ SHOES_EVENTS_URL: network.url,
+ SHOES_DEALS_URL: upstreams.url,
+ SHOES_DROPS_URL: `${upstreams.url}/launch`,
+ SHOES_DROPS_SOURCE_NAME: 'Nike SNKRS launch calendar',
+ SHOES_DROP_FEEDS: JSON.stringify([{ url: `${upstreams.url}/feed`, name: 'Example releases', type: 'rss' }]),
+ SHOES_WATCH_INTERNAL_URL: upstreams.url, SHOES_WATCH_TOKEN: 'watch-test-token',
+ SHOES_TODAY_INTERNAL_URL: upstreams.url,
+ SHOES_EBAY_CLIENT_ID: 'ebay-id', SHOES_EBAY_CLIENT_SECRET: 'ebay-secret', SHOES_EBAY_API_URL: upstreams.url,
+ SHOES_UPSTREAM_TIMEOUT_MS: '4000',
+ ...(opts.env || {}),
+ };
+ for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];
+ const configLib = require('../../server/config');
+ const { createApp } = require('../../server/app');
+ const log = { log: (...a) => captured.push(a.map(String).join(' ')), warn: (...a) => captured.push(a.map(String).join(' ')), error: (...a) => captured.push(a.map(String).join(' ')), info: (...a) => captured.push(a.map(String).join(' ')) };
+
+ const config = configLib.load(env);
+ const { createStore } = require('../../server/db');
+ const testdb = await require('./db').testDb();
+ const store = createStore(testdb.db, { now: opts.now });
+ const built = await createApp({
+ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend,
+ urlCheck: opts.urlCheck || (async () => {}),
+ });
+ await built.ctx.keys.ensure();
+ const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+
+ const signIn = (user) => `shoes_at=${network.userToken(user)}`;
+
+ async function get(p, o = {}) {
+ const headers = { ...(o.headers || {}) };
+ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');
+ if (o.cookie) headers.cookie = o.cookie;
+ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;
+ let body = o.body;
+ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
+ if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
+ const method = o.method || (body ? 'POST' : 'GET');
+ if (o.as && method !== 'GET' && method !== 'HEAD' && !headers.origin && !headers['sec-fetch-site']) headers.origin = config.baseUrl;
+ if (o.origin) headers.origin = o.origin;
+ const res = await fetch(base + p, { method, headers, body, redirect: 'manual' });
+ const buf = Buffer.from(await res.arrayBuffer());
+ const text = buf.toString('utf8');
+ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };
+ }
+
+ async function dbDump() {
+ const db = built.ctx.s.db;
+ const tables = (await db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()")).map((r) => r.name);
+ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.many(`SELECT * FROM "${tname}"`))))).join('\n');
+ }
+
+ const t = {
+ base, network, upstreams, config, ctx: built.ctx, get, signIn, dbDump,
+ logs: () => captured.join('\n'),
+ async close() {
+ built.ctx.jobs.stop();
+ await new Promise((r) => server.close(r));
+ built.ctx.keys.client.stop();
+ await built.ctx.telemetry.stop().catch(() => {});
+ await testdb.close();
+ await network.close();
+ await upstreams.close();
+ },
+ };
+ return t;
+}
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }
+}
+function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }
+
+module.exports = { boot, check, done };
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+'use strict';
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }
+}
+function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }
+function assert(cond, message) { if (!cond) throw new Error(message || 'assertion failed'); }
+function equal(a, b, message) { if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(`${message || 'not equal'}: ${JSON.stringify(a)} !== ${JSON.stringify(b)}`); }
+module.exports = { check, done, assert, equal };
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+'use strict';
+/**
+ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
+ * SHOES_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.
+ */
+const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');
+const { MIGRATIONS } = require('../../server/db');
+
+const testDb = ({ store = process.env.SHOES_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'shoes', max });
+
+module.exports = { testDb, pgAvailable };
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+'use strict';
+/**
+ * An in-process stand-in for OpenVibe.Network with a real RS256 key pair.
+ *
+ * Network JWKS; /oauth/token (authorization_code + PKCE S256 for client `shoes`, refresh_token,
+ * client_credentials); /oauth/revoke; GET /api/v1/me/follows (not used by Shoes, kept for parity);
+ * POST /internal/identity/resolve-batch; POST /internal/notifications/push.
+ *
+ * Every request is recorded in `.requests`, token exchanges in `.tokenRequests`.
+ */
+const http = require('http');
+const crypto = require('crypto');
+const { serviceAuth, ids } = require('openvibe-contracts');
+
+function listen(handler) {
+ return new Promise((resolve) => {
+ const sockets = new Set();
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks);
+ const json = (status, obj, headers = {}) => { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); };
+ Promise.resolve(handler(req, raw, json, res)).catch((err) => { json(500, { error: String(err && err.message) }); });
+ });
+ });
+ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
+ server.listen(0, '127.0.0.1', () => resolve({
+ server, url: `http://127.0.0.1:${server.address().port}`,
+ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),
+ }));
+ });
+}
+
+const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
+function signJwt(claims, privateKey) {
+ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}`;
+ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
+}
+
+async function startNetwork() {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
+ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
+ const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], down: false };
+ let issuer = null;
+ let nextUserId = 1;
+
+ const problem = (json, status, code, detail) => json(status, { type: `https://openvibe.network/problems/${code}`, title: 'Error', status, code, detail, error: detail, request_id: `req_${crypto.randomBytes(6).toString('hex')}` }, { 'Content-Type': 'application/problem+json' });
+
+ function addUser(username, { role = 'user' } = {}) {
+ const u = { id: nextUserId++, subject: ids.newId('user'), username, display_name: username, role };
+ st.users.set(u.id, u); st.byUsername.set(username, u);
+ return u;
+ }
+ function userToken(u, { ttl = 3600, iat = null } = {}) {
+ const now = iat != null ? iat : Math.floor(Date.now() / 1000);
+ return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);
+ }
+ function userFrom(req) {
+ const h = String(req.headers.authorization || '');
+ if (!h.startsWith('Bearer ')) return null;
+ const parts = h.slice(7).split('.');
+ if (parts.length !== 3) return null;
+ const ok = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicPem, Buffer.from(parts[2], 'base64url'));
+ if (!ok) return null;
+ const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
+ if (c.exp * 1000 < Date.now()) return 'expired';
+ return st.users.get(c.sub) || null;
+ }
+
+ const srv = await listen(async (req, raw, json) => {
+ const url = new URL(req.url, 'http://x');
+ st.requests.push({ method: req.method, path: url.pathname, query: url.search, headers: req.headers, body: raw.toString('utf8') });
+ if (st.down) return problem(json, 503, 'network.down', 'Network is restarting');
+
+ if (url.pathname === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
+ if (url.pathname === '/.well-known/openvibe') return json(200, { name: 'OpenVibe', issuer, contracts: { version: require('openvibe-contracts/package.json').version }, services: [] });
+ if (url.pathname === '/oauth/revoke') return json(200, {});
+ if (url.pathname === '/oauth/token' && req.method === 'POST') {
+ const ct = String(req.headers['content-type'] || '');
+ const body = ct.includes('json') ? JSON.parse(raw.toString('utf8') || '{}') : Object.fromEntries(new URLSearchParams(raw.toString('utf8')));
+ st.tokenRequests.push({ grant_type: body.grant_type, client_id: body.client_id, audience: body.audience, scope: body.scope });
+ if (body.client_id !== 'shoes' || body.client_secret !== 'shoes-secret') return json(401, { error: 'invalid_client', error_description: 'bad client secret' });
+ if (body.grant_type === 'authorization_code') {
+ const c = st.codes.get(body.code);
+ if (!c) return json(400, { error: 'invalid_grant', error_description: 'unknown code' });
+ st.codes.delete(body.code);
+ const challenge = crypto.createHash('sha256').update(String(body.code_verifier || '')).digest('base64url');
+ if (challenge !== c.challenge) return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
+ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;
+ st.refresh.set(rt, c.user);
+ return json(200, { access_token: userToken(c.user), refresh_token: rt, token_type: 'Bearer' });
+ }
+ if (body.grant_type === 'refresh_token') {
+ const user = st.refresh.get(body.refresh_token);
+ if (!user) return json(400, { error: 'invalid_grant', error_description: 'refresh token rejected' });
+ st.refresh.delete(body.refresh_token);
+ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;
+ st.refresh.set(rt, user);
+ return json(200, { access_token: userToken(user), refresh_token: rt, token_type: 'Bearer' });
+ }
+ if (body.grant_type === 'client_credentials') {
+ const now = Math.floor(Date.now() / 1000);
+ const caps = String(body.scope || '').split(/\s+/).filter(Boolean);
+ return json(200, {
+ access_token: serviceAuth.signServiceToken({ iss: issuer, sub: 'svc:shoes', actor_type: 'service', aud: [body.audience || 'openvibe.network'], cap: caps, iat: now, exp: now + 300, jti: crypto.randomUUID() }, privatePem),
+ token_type: 'Bearer', expires_in: 300, scope: caps.join(' '),
+ });
+ }
+ return json(400, { error: 'unsupported_grant_type' });
+ }
+
+ if (url.pathname === '/api/v1/me/follows' && req.method === 'GET') {
+ const u = userFrom(req);
+ if (u === 'expired') return problem(json, 401, 'auth.expired', 'expired');
+ if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');
+ return json(200, { items: [], next_cursor: null });
+ }
+
+ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') return json(200, { results: {} });
+ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });
+
+ return json(404, { error: 'not found' });
+ });
+ issuer = srv.url;
+
+ return {
+ url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,
+ addUser, userToken, signJwt: (claims) => signJwt(claims, privatePem), issuer: () => issuer,
+ issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
+ setDown(v) { st.down = v; },
+ close: srv.close,
+ };
+}
+
+module.exports = { startNetwork, signJwt };
new file mode 100644
new file mode 100644
@@ -0,0 +1,162 @@
@@ -0,0 +1,162 @@
+'use strict';
+
+/**
+ * In-process stand-ins for the public sources and the sibling services Shoes uses:
+ *
+ * OpenVibe.Deals GET /api/v1/offers?sort=new&page=1 (the fixture below; page 2 is empty)
+ * GET /api/v1/products/:slug (brand + name)
+ * OpenVibe.Watch POST /api/v1/watches (records the body; answers { watch })
+ * OpenVibe.Today POST /api/v1/events (records the body; answers { event })
+ * a release page GET /launch (the Nike SNKRS JSON payload) and GET /feed (plain RSS)
+ * eBay POST /identity/v1/oauth2/token and GET /buy/browse/v1/item_summary/search
+ *
+ * The fixtures are chosen so the classifier, the size parser and the all-in price all have something real to do.
+ */
+const http = require('http');
+
+const DAY = '2026-10-31T20:00:00.000Z';
+
+const DEALS = [
+ {
+ id: 'dof_test_airmax', slug: 'air-max-90-white',
+ title: 'Nike Air Max 90 Running Shoe - White', link: 'https://www.zappos.com/p/nike-air-max-90/product/1/color/2',
+ store: { domain: 'zappos.com', name: 'Zappos' }, product: { slug: 'air-max-90', name: 'Air Max 90' },
+ latest_observation: { price: '89.95', list_price: '130.00', shipping: '0.00', condition: 'new', currency: 'USD', observed_at: DAY },
+ },
+ {
+ id: 'dof_test_jordan', slug: 'air-jordan-1-mid',
+ title: 'Air Jordan 1 Mid Sneaker', link: 'https://www.footlocker.com/product/air-jordan-1-mid',
+ store: { domain: 'footlocker.com', name: 'Foot Locker' }, product: { slug: 'air-jordan-1-mid', name: 'Air Jordan 1 Mid' },
+ latest_observation: { price: '120.00', list_price: null, shipping: '5.99', condition: 'new', currency: 'USD', observed_at: DAY },
+ },
+ {
+ id: 'dof_test_samba', slug: 'adidas-samba-og-size-10-5',
+ title: 'Adidas Samba OG Shoes Size 10.5', link: 'https://www.adidas.com/us/samba-og',
+ store: { domain: 'adidas.com', name: 'adidas' }, product: { slug: 'samba-og', name: 'Samba OG' },
+ latest_observation: { price: '100.00', list_price: null, shipping: null, condition: 'new', currency: 'USD', observed_at: DAY },
+ },
+ {
+ id: 'dof_test_joggers', slug: 'rival-fleece-joggers',
+ title: 'Under Armour Rival Fleece Joggers', link: 'https://www.zappos.com/p/under-armour-joggers',
+ store: { domain: 'zappos.com', name: 'Zappos' }, product: { slug: 'rival-fleece-joggers', name: 'Rival Fleece Joggers' },
+ latest_observation: { price: '40.00', list_price: null, shipping: null, condition: null, currency: 'USD', observed_at: DAY },
+ },
+ {
+ id: 'dof_test_990', slug: 'new-balance-990v6',
+ title: 'New Balance 990v6 Running Shoe', link: 'https://www.newbalance.com/pd/990v6',
+ store: { domain: 'newbalance.com', name: 'New Balance' }, product: { slug: '990v6', name: '990v6' },
+ latest_observation: { price: '149.99', list_price: '199.99', shipping: '0.00', condition: 'new', currency: 'USD', observed_at: DAY },
+ },
+];
+
+const PRODUCTS = {
+ 'air-max-90': { slug: 'air-max-90', name: 'Air Max 90', brand: 'Nike' },
+ 'air-jordan-1-mid': { slug: 'air-jordan-1-mid', name: 'Air Jordan 1 Mid', brand: 'Jordan' },
+ 'samba-og': { slug: 'samba-og', name: 'Samba OG', brand: 'Adidas' },
+ 'rival-fleece-joggers': { slug: 'rival-fleece-joggers', name: 'Rival Fleece Joggers', brand: 'Under Armour' },
+ '990v6': { slug: '990v6', name: '990v6', brand: 'New Balance' },
+};
+
+const LAUNCH_HTML = (() => {
+ const payload = JSON.stringify({
+ products: {
+ data: {
+ items: {
+ a: { commerceStartDate: '2026-11-04T15:00:00.000Z', title: 'Air Max 90 "Medium Olive"', subtitle: "Men's Shoes", fullPrice: 130, styleColor: 'IV4504-201', skus: [{ nike_size: '10.5' }, { nike_size: '9' }] },
+ b: { commerceStartDate: '2026-11-07T15:00:00.000Z', title: 'Air Jordan 6 Retro "White and Black"', subtitle: "Men's Shoes", fullPrice: 215, styleColor: 'CT8529-105', skus: [{ nike_size: '9.5' }] },
+ },
+ },
+ },
+ });
+ // The page carries the JSON in a JavaScript string literal, exactly as Nike's launch page does.
+ return `<!doctype html><html><body><script>window.__INITIAL_STATE__ = ${JSON.stringify(payload)};</script></body></html>`;
+})();
+
+const RSS = `<?xml version="1.0"?><rss version="2.0"><channel><title>Example releases</title>
+<item><title>Puma Suede Classic Sneaker</title><link>https://example.com/releases/puma-suede</link><pubDate>Wed, 11 Nov 2026 10:00:00 GMT</pubDate></item>
+<item><title>No date here</title><link>https://example.com/releases/none</link></item>
+</channel></rss>`;
+
+const EBAY_ITEMS = [
+ {
+ itemId: 'v1|123|0', title: 'Nike Pegasus 41 Running Shoes Size 10.5', itemWebUrl: 'https://www.ebay.com/itm/123',
+ price: { value: '79.99', currency: 'USD' }, shippingOptions: [{ shippingCost: { value: '4.99', currency: 'USD' } }],
+ condition: 'New with box', seller: { username: 'runner' },
+ },
+ {
+ itemId: 'v1|124|0', title: 'ASICS Gel-Kayano 31', itemWebUrl: 'https://www.ebay.com/itm/124',
+ price: { value: '120.00', currency: 'USD' }, shippingOptions: [], condition: 'Pre-owned', seller: { username: 'shoeguy' },
+ },
+];
+
+function listen(handler) {
+ return new Promise((resolve) => {
+ const sockets = new Set();
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks).toString('utf8');
+ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ Promise.resolve(handler(req, raw, json, res)).catch((err) => json(500, { error: String(err && err.message) }));
+ });
+ });
+ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
+ server.listen(0, '127.0.0.1', () => resolve({
+ server, url: `http://127.0.0.1:${server.address().port}`,
+ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),
+ }));
+ });
+}
+
+async function startUpstreams() {
+ const state = { requests: [], watchRequests: [], todayRequests: [], offers: DEALS.map((o) => JSON.parse(JSON.stringify(o))), ebayItems: EBAY_ITEMS.map((i) => JSON.parse(JSON.stringify(i))) };
+
+ const srv = await listen(async (req, raw, json, res) => {
+ const url = new URL(req.url, 'http://x');
+ const p = url.pathname;
+ state.requests.push({ method: req.method, path: p, query: Object.fromEntries(url.searchParams), body: raw });
+
+ if (p === '/api/v1/offers') {
+ const page = Number(url.searchParams.get('page') || 1);
+ return json(200, { sort: 'new', page, total: state.offers.length, offers: page === 1 ? state.offers : [] });
+ }
+ const product = p.match(/^\/api\/v1\/products\/([^/]+)$/);
+ if (product) {
+ const found = PRODUCTS[decodeURIComponent(product[1])];
+ return found ? json(200, { product: found, offers: [] }) : json(404, { error: 'product.not_found' });
+ }
+ if (p === '/api/v1/watches' && req.method === 'POST') {
+ let body = {};
+ try { body = JSON.parse(raw || '{}'); } catch { body = {}; }
+ state.watchRequests.push({ body, headers: { ...req.headers } });
+ return json(201, { watch: { id: `wch_${String(state.watchRequests.length).padStart(26, '0')}`, ...body } });
+ }
+ if (p === '/api/v1/watches' && req.method === 'GET') return json(200, { watches: [] });
+ if (p === '/api/v1/events' && req.method === 'POST') {
+ let body = {};
+ try { body = JSON.parse(raw || '{}'); } catch { body = {}; }
+ state.todayRequests.push({ body, headers: { ...req.headers } });
+ return json(201, { event: { id: `evt_${String(state.todayRequests.length).padStart(26, '0')}`, ...body } });
+ }
+ if (p === '/launch') {
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+ return res.end(LAUNCH_HTML);
+ }
+ if (p === '/feed') {
+ res.writeHead(200, { 'Content-Type': 'application/rss+xml' });
+ return res.end(RSS);
+ }
+ if (p === '/identity/v1/oauth2/token') return json(200, { access_token: 'ebay-test-token', token_type: 'Bearer', expires_in: 3600 });
+ if (p === '/buy/browse/v1/item_summary/search') return json(200, { itemSummaries: state.ebayItems });
+ return json(404, { error: 'not found' });
+ });
+
+ return {
+ url: srv.url, state, close: srv.close,
+ requestsFor(path) { return state.requests.filter((r) => r.path === path); },
+ lastFor(path) { return state.requests.filter((r) => r.path === path).pop() || null; },
+ };
+}
+
+module.exports = { startUpstreams, DEALS, PRODUCTS, LAUNCH_HTML, RSS, EBAY_ITEMS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+'use strict';
+
+/**
+ * The jobs (server/jobs/*) against in-process public sources: the offers job keeps only footwear, gets the brand
+ * from the product, writes one snapshot per offer per day and never duplicates on a second run; the drops job
+ * keeps only dated releases; the eBay job uses the official Browse API and is off without keys.
+ */
+const { check, done, assert, equal } = require('./helpers/check');
+const configLib = require('../server/config');
+const { testDb } = require('./helpers/db');
+const { createStore } = require('../server/db');
+const { createCatalogue } = require('../server/catalogue');
+const { createUpstreams } = require('../server/upstreams');
+const { createOffersJob } = require('../server/jobs/offers');
+const { createDropsJob } = require('../server/jobs/drops');
+const { createEbayJob } = require('../server/jobs/ebay');
+const { startUpstreams } = require('./helpers/upstreams');
+
+(async () => {
+ const upstream = await startUpstreams();
+ const test = await testDb();
+ const config = configLib.load({
+ NODE_ENV: 'test', BASE_URL: 'https://open.shoes',
+ SHOES_DEALS_URL: upstream.url, SHOES_OFFERS_PAGES: '2', SHOES_PRODUCT_LOOKUPS: '20',
+ SHOES_DROPS_URL: `${upstream.url}/launch`, SHOES_DROPS_SOURCE_NAME: 'Nike SNKRS launch calendar',
+ SHOES_DROP_FEEDS: JSON.stringify([{ url: `${upstream.url}/feed`, name: 'Example releases', type: 'rss' }]),
+ SHOES_EBAY_CLIENT_ID: 'ebay-id', SHOES_EBAY_CLIENT_SECRET: 'ebay-secret', SHOES_EBAY_API_URL: upstream.url,
+ SHOES_EBAY_LIMIT: '10', SHOES_UPSTREAM_TIMEOUT_MS: '4000',
+ });
+ const s = createStore(test.db, { now: () => Date.parse('2026-10-31T22:00:00Z') });
+ const catalogue = createCatalogue(s, { staleHours: 48 });
+ const upstreams = createUpstreams({ config, log: { warn() {}, log() {} } });
+ const offersJob = createOffersJob({ config, catalogue, upstreams, log: { log() {}, warn() {} } });
+ const dropsJob = createDropsJob({ config, catalogue, upstreams, log: { log() {}, warn() {} } });
+ const ebayJob = createEbayJob({ config, catalogue, upstreams, log: { log() {}, warn() {} } });
+
+ try {
+ await check('offers: only footwear, brand from the product, one model each', async () => {
+ const out = await offersJob.runOnce({ pages: 2 });
+ equal(out.seen, 5);
+ equal(out.candidates, 4, 'the joggers are not footwear');
+ equal(out.offers, 4);
+ const counts = await catalogue.counts();
+ equal(counts.models, 4);
+ equal(counts.offers, 4);
+ equal(counts.snapshots, 4);
+ const airmax = await catalogue.modelBySlug('nike-air-max-90');
+ assert(airmax, 'Nike Air Max 90 is a model');
+ const jordan = await catalogue.modelBySlug('jordan-1-mid');
+ assert(jordan, 'Air Jordan 1 Mid is the model jordan-1-mid');
+ equal(jordan.brand, 'Jordan');
+ equal(jordan.model, '1 Mid');
+ const samba = await catalogue.modelBySlug('adidas-samba-og');
+ assert(samba, 'Samba OG model');
+ const offers = await catalogue.offersOf(airmax.id);
+ equal(offers.length, 1);
+ equal(offers[0].all_in, 89.95);
+ equal(offers[0].drop_from_list, 40.05);
+ });
+
+ await check('offers: a second run does not duplicate an offer or snapshots', async () => {
+ const out = await offersJob.runOnce({ pages: 2 });
+ equal(out.offers, 4);
+ const counts = await catalogue.counts();
+ equal(counts.offers, 4);
+ equal(counts.snapshots, 4);
+ });
+
+ await check('drops: the launch calendar and the RSS feed, only dated releases', async () => {
+ const out = await dropsJob.runOnce();
+ equal(out.sources, 2);
+ equal(out.releases, 3, 'two launch releases and one RSS item');
+ equal(out.drops, 3);
+ const drops = await catalogue.listDrops({ from: '2026-11-01T00:00:00Z' });
+ equal(drops.length, 3);
+ assert(drops.some((d) => d.brand === 'Puma' && d.model === 'Suede Classic'), 'the RSS item');
+ assert(drops.every((d) => d.source_url.startsWith('http')), 'every drop cites its page');
+ });
+
+ await check('ebay: the official Browse API, with the seller and its shipping', async () => {
+ assert(upstreams.ebayConfigured(), 'configured');
+ const out = await ebayJob.runOnce();
+ assert(out.configured, 'ran');
+ equal(out.items, 2);
+ equal(out.offers, 2);
+ const pegasus = (await catalogue.listShoes({ q: 'Pegasus' }))[0];
+ assert(pegasus, 'the eBay Pegasus is in the catalogue');
+ const offer = (await catalogue.offersOf(pegasus.id)).find((o) => o.source === 'ebay');
+ assert(offer, 'the eBay offer exists');
+ equal(offer.size, '10.5');
+ equal(offer.all_in, 84.98);
+ });
+
+ await check('ebay: off without a key pair, a plain note not an error', async () => {
+ const cfg = configLib.load({ NODE_ENV: 'test', BASE_URL: 'https://open.shoes', SHOES_EBAY_CLIENT_ID: '', SHOES_EBAY_CLIENT_SECRET: '' });
+ const off = createEbayJob({ config: cfg, catalogue, upstreams: createUpstreams({ config: cfg, log: { warn() {} } }), log: { log() {}, warn() {} } });
+ const out = await off.runOnce();
+ equal(out.configured, false);
+ equal(out.offers, 0);
+ assert(/off/.test(out.note), 'the note says it is off');
+ });
+
+ await check('the job record says when each source was read', async () => {
+ const last = await catalogue.lastJobRuns();
+ const names = last.map((j) => j.job).sort();
+ equal(names, ['drops', 'ebay', 'offers']);
+ assert(last.every((j) => j.finished_at && j.ok), 'every job finished ok');
+ assert(last.find((j) => j.job === 'offers').counts.offers === 4, 'the offers count is recorded');
+ });
+ } finally {
+ await test.close();
+ await upstream.close();
+ }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+
+/** The migration: every table the service writes and the constraints that keep an invented row out. */
+const { check, done, assert, equal } = require('./helpers/check');
+const { testDb } = require('./helpers/db');
+
+(async () => {
+ const test = await testDb();
+ const db = test.db;
+ try {
+ const tables = (await db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() ORDER BY table_name")).map((r) => r.name);
+ for (const t of ['shoe_models', 'shoe_offers', 'shoe_snapshots', 'shoe_drops', 'shoe_watches', 'shoe_facts', 'shoe_job_runs', 'account_data_events', 'telemetry_samples']) {
+ assert(tables.includes(t), `missing table ${t}`);
+ }
+
+ await check('a model, an offer and a snapshot accept the ids and amounts the code writes', async () => {
+ await db.exec(`INSERT INTO shoe_models (id, slug, brand, model, name, first_seen_at) VALUES ('shm_01J000000000000000000000AA', 'nike-air-max-90', 'Nike', 'Air Max 90', 'Air Max 90', now())`);
+ await db.exec(`INSERT INTO shoe_offers (id, model_id, source, source_id, store_domain, title, url, price_amount, shipping_amount, checked_at, size, condition)
+ VALUES ('sho_01J000000000000000000000AA', 'shm_01J000000000000000000000AA', 'deals', 'd1', 'zappos.com', 'Nike Air Max 90', 'https://www.zappos.com/p/1', 89.95, 0, now(), '10.5', 'new')`);
+ await db.exec(`INSERT INTO shoe_snapshots (id, offer_id, model_id, price_amount, checked_at, day) VALUES ('shs_01J000000000000000000000AA', 'sho_01J000000000000000000000AA', 'shm_01J000000000000000000000AA', 89.95, now(), current_date)`);
+ equal(Number(await db.value('SELECT COUNT(*)::int FROM shoe_offers')), 1);
+ });
+
+ await check('one snapshot per offer per day is a constraint, not a convention', async () => {
+ let failed = false;
+ try {
+ await db.exec(`INSERT INTO shoe_snapshots (id, offer_id, model_id, price_amount, checked_at, day) VALUES ('shs_01J000000000000000000000AB', 'sho_01J000000000000000000000AA', 'shm_01J000000000000000000000AA', 88.00, now(), current_date)`);
+ } catch { failed = true; }
+ assert(failed, 'the unique (offer_id, day) refused a second point');
+ });
+
+ await check('the id shapes, conditions and URLs are checked by the database', async () => {
+ let failed = 0;
+ for (const sql of [
+ `INSERT INTO shoe_models (id, slug, brand, model, name, first_seen_at) VALUES ('bad', 'x', 'Nike', 'X', 'X', now())`,
+ `INSERT INTO shoe_offers (id, model_id, source, source_id, store_domain, title, url, checked_at) VALUES ('sho_01J000000000000000000000AC', 'shm_01J000000000000000000000AA', 'deals', 'd2', 'x.com', 'X', 'not a url', now())`,
+ `INSERT INTO shoe_offers (id, model_id, source, source_id, store_domain, title, url, condition, checked_at) VALUES ('sho_01J000000000000000000000AD', 'shm_01J000000000000000000000AA', 'deals', 'd3', 'x.com', 'X', 'https://x.com/1', 'refurbished', now())`,
+ `INSERT INTO shoe_watches (id, subject, kind, status) VALUES ('shw_01J000000000000000000000AA', 'usr_01J000000000000000000000AA', 'guess', 'active')`,
+ `INSERT INTO shoe_facts (id, model_id, kind, label, value, source_name, source_url, fetched_at) VALUES ('shf_01J000000000000000000000AA', 'shm_01J000000000000000000000AA', 'guess', 'x', 'y', 'z', 'https://example.com', now())`,
+ ]) {
+ try { await db.exec(sql); } catch { failed++; }
+ }
+ equal(failed, 5);
+ });
+
+ await check('the same source id cannot make two offers', async () => {
+ let failed = false;
+ try {
+ await db.exec(`INSERT INTO shoe_offers (id, model_id, source, source_id, store_domain, title, url, checked_at) VALUES ('sho_01J000000000000000000000AE', 'shm_01J000000000000000000000AA', 'deals', 'd1', 'x.com', 'X', 'https://x.com/1', now())`);
+ } catch { failed = true; }
+ assert(failed, 'the unique (source, source_id) refused it');
+ });
+ } finally {
+ await test.close();
+ }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,158 @@
@@ -0,0 +1,158 @@
+'use strict';
+
+/**
+ * Every page: the room's front door (hero, proof, rails, scenes, explore, dev, FAQ, network), the search, one
+ * shoe, the drops with a real "remind me" onto Today, a pasted link, the signed-in watches, the developers page,
+ * the discovery artifacts and the 404.
+ */
+const { boot, check, done } = require('./helpers/boot');
+const { assert, equal } = require('./helpers/check');
+
+(async () => {
+ const t = await boot();
+ try {
+ await t.ctx.jobs.offers.runOnce({ pages: 2 });
+ await t.ctx.jobs.drops.runOnce();
+ const user = t.network.addUser('ada');
+
+ await check('GET /: sc.room with every section from real data', async () => {
+ const res = await t.get('/');
+ equal(res.status, 200);
+ assert(res.headers.get('content-type').includes('text/html'), 'html');
+ const h = res.text;
+ assert(h.includes('class="ov-room sc-room"'), 'the room wrapper');
+ assert(h.includes('data-room="shoes"'), 'the room id');
+ assert(h.includes('Every shoe, every market, the right price'), 'the room line');
+ assert(h.includes('The biggest price drops today'), 'the drops rail');
+ assert(h.includes('Dropping this week'), 'the release rail');
+ assert(h.includes('Popular models'), 'the popular rail');
+ assert(h.includes('The biggest price drops today') && h.includes('ovk-card'), 'kit cards');
+ assert(h.includes('A size-price curve') || h.includes('What a size costs'), 'the size scene');
+ assert(h.includes('A drop reminder, on Today'), 'the Today scene');
+ assert(h.includes('Paste a link, keep the price'), 'the link scene');
+ assert(h.includes('For developers'), 'the dev section');
+ assert(h.includes('Questions') || h.includes('sc-faq'), 'the FAQ');
+ assert(h.includes('Part of OpenVibe'), 'the network section');
+ assert(!h.includes('undefined'), 'no undefined in the page');
+ assert(h.includes('OpenVibe.Shoes') && h.includes('open.shoes'), 'the name and the domain');
+ });
+
+ await check('GET /shoes: search, facets and a real card', async () => {
+ const res = await t.get('/shoes?brand=Nike');
+ equal(res.status, 200);
+ assert(res.text.includes('Nike Air Max 90'), 'the model');
+ assert(res.text.includes('$89.95'), 'the all-in price');
+ assert(res.text.includes('Brand'), 'the brand facet');
+ assert(res.text.includes('1 model'), 'the count');
+ });
+
+ await check('GET /shoe/:slug: offers, history, curve, facts and the watch form', async () => {
+ const res = await t.get('/shoe/nike-air-max-90');
+ equal(res.status, 200);
+ const h = res.text;
+ assert(h.includes('Nike Air Max 90'), 'the title');
+ assert(h.includes('$89.95'), 'the all-in price');
+ assert(h.includes('Current prices by store'), 'the offers');
+ assert(h.includes('zappos.com'), 'the store');
+ assert(h.includes('Price history'), 'the history');
+ assert(h.includes('2026-10-31'), 'the snapshot day');
+ assert(h.includes('Size-price curve'), 'the curve section');
+ assert(h.includes('Not known yet'), 'the honest unknown');
+ assert(h.includes('Release dates'), 'the cited releases section');
+ assert(h.includes('Medium Olive'), 'the official release colourway');
+ assert(h.includes('A price watch needs a person'), 'signed out prompt');
+ assert(h.includes('90.00') || h.includes('from list'), 'the list-price drop');
+ });
+
+ await check('GET /shoe/:slug signed in: the price-watch form', async () => {
+ const res = await t.get('/shoe/nike-air-max-90', { as: user });
+ equal(res.status, 200);
+ assert(res.text.includes('Tell me when this drops below $X at my size'), 'the form');
+ assert(res.text.includes('name="threshold"'), 'the price input');
+ });
+
+ await check('GET /drops: the official calendar and the reminder', async () => {
+ const res = await t.get('/drops');
+ equal(res.status, 200);
+ const h = res.text;
+ assert(h.includes('Dropping this week and after'), 'the heading');
+ assert(h.includes('Air Max 90'), 'a release');
+ assert(h.includes('Nike SNKRS launch calendar'), 'the source');
+ assert(h.includes('Never automated entries'), 'the raffle rule');
+ assert(h.includes('Sign in to be reminded'), 'signed out prompt');
+ });
+
+ await check('POST /drops/:id/remind: the drop goes on the person’s Today agenda', async () => {
+ const drops = (await t.get('/api/v1/drops')).json().drops;
+ const future = drops.find((d) => Date.parse(d.release_at) > Date.now());
+ assert(future, 'a future release');
+ const res = await t.get(`/drops/${encodeURIComponent(future.id)}/remind`, { as: user, method: 'POST' });
+ equal(res.status, 302);
+ assert(res.headers.get('location').startsWith('/drops?notice='), 'back to the calendar');
+ equal(t.upstreams.state.todayRequests.length, 1);
+ const sent = t.upstreams.state.todayRequests[0];
+ equal(sent.body.kind, 'event');
+ assert(sent.body.title.startsWith('Drop: '), 'the event title');
+ equal(sent.body.starts_at, future.release_at);
+ assert(sent.headers.authorization.startsWith('Bearer '), 'the person token');
+ });
+
+ await check('GET /track and POST /track: a pasted link becomes a watch', async () => {
+ const page = await t.get('/track', { as: user });
+ equal(page.status, 200);
+ assert(page.text.includes('name="url"'), 'the form');
+ const res = await t.get('/track', { as: user, form: { url: 'https://www.ebay.com/itm/999' } });
+ equal(res.status, 302);
+ assert(res.headers.get('location').startsWith('/watches?notice='), 'to the watches');
+ const watches = (await t.get('/api/v1/me/watches', { as: user })).json().watches;
+ assert(watches.some((w) => w.kind === 'link' && w.source_url === 'https://www.ebay.com/itm/999'), 'the link watch');
+ });
+
+ await check('GET /watches signed in: the watches, with the Watch note', async () => {
+ const res = await t.get('/watches?notice=hello', { as: user });
+ equal(res.status, 200);
+ assert(res.text.includes('Watches'), 'the heading');
+ assert(res.text.includes('hello'), 'the notice');
+ assert(res.text.includes('https://www.ebay.com/itm/999'), 'the pasted link');
+ const out = await t.get('/watches');
+ assert(out.text.includes('Sign in to see your watches'), 'signed out');
+ });
+
+ await check('GET /developers: the routes, sources and examples', async () => {
+ const res = await t.get('/developers');
+ equal(res.status, 200);
+ const h = res.text;
+ assert(h.includes('The Shoes API'), 'the heading');
+ assert(h.includes('/api/v1/shoes'), 'the routes');
+ assert(h.includes('/api/openapi.json'), 'OpenAPI');
+ assert(h.includes('No scraping'), 'the source rule');
+ assert(h.includes('OpenVibe.Watch'), 'the watch service');
+ });
+
+ await check('discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt', async () => {
+ const robots = await t.get('/robots.txt');
+ equal(robots.status, 200);
+ assert(robots.text.includes('Sitemap: https://open.shoes/sitemap.xml'), 'the sitemap');
+ assert(robots.text.includes('/watches'), 'the private page is disallowed');
+ const sitemap = await t.get('/sitemap.xml');
+ equal(sitemap.status, 200);
+ assert(sitemap.text.includes('/shoe/nike-air-max-90'), 'the model page');
+ const llms = await t.get('/llms.txt');
+ equal(llms.status, 200);
+ assert(llms.text.includes('OpenVibe.Shoes'), 'the llms index');
+ const full = await t.get('/llms-full.txt');
+ equal(full.status, 200);
+ assert(full.text.includes('Nike Air Max 90'), 'the full pages');
+ });
+
+ await check('a missing page is 404 through the layout', async () => {
+ const res = await t.get('/no-such-page');
+ equal(res.status, 404);
+ assert(res.text.includes('No such page'), 'the not-found page');
+ equal((await t.get('/shoe/no-such-shoe')).status, 404);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
+'use strict';
+
+/**
+ * The pure rules (server/domain/parse.js). These are the promises the pages make: a size is shown only when a
+ * source names one, a shoe is footwear, the brand and model are not guessed, and an all-in price never invents
+ * shipping.
+ */
+const { check, done, assert, equal } = require('./helpers/check');
+const parse = require('../server/domain/parse');
+
+(async () => {
+ await check('isShoe: footwear words and shoe-only models', () => {
+ assert(parse.isShoe('Nike Air Max 90 Running Shoe - White'), 'running shoe');
+ assert(parse.isShoe('Air Jordan 1 Mid Sneaker'), 'sneaker');
+ assert(parse.isShoe('Nike Air Force 1 \'07'), 'a model only a shoe has, no footwear word');
+ assert(parse.isShoe('Birkenstock Boston Clog'), 'clog');
+ assert(!parse.isShoe('Under Armour Rival Fleece Joggers'), 'joggers are not shoes');
+ assert(!parse.isShoe('Nike Dri-FIT T-Shirt'), 'a shirt with a shoe brand is not a shoe');
+ assert(!parse.isShoe('The North Face Hoodie'), 'a hoodie is not a shoe');
+ });
+
+ await check('brandOf: the source brand first, the title second', () => {
+ equal(parse.brandOf('Air Max 90', 'Nike'), 'Nike');
+ equal(parse.brandOf('air jordan 1 mid', null), 'Jordan', 'Air Jordan is the Jordan brand');
+ equal(parse.brandOf('New Balance 990v6', null), 'New Balance');
+ equal(parse.brandOf('ASICS Gel-Kayano 31', null), 'ASICS');
+ equal(parse.brandOf('Samba OG', 'adidas'), 'Adidas', 'a known brand is canonical, not a second spelling');
+ equal(parse.brandOf('Pickleball', 'SKECHERS'), 'Skechers');
+ equal(parse.brandOf('A shoe with no brand', null), null);
+ });
+
+ await check('modelOf: person words, the brand, the kind and a quoted colourway taken off', () => {
+ equal(parse.modelOf("Women's Nike Air Max 90 Running Shoe", 'Nike'), 'Air Max 90');
+ equal(parse.modelOf('Air Jordan 14 Retro "Forest Green"', 'Jordan'), '14 Retro');
+ equal(parse.modelOf('Adidas Samba OG Shoes Size 10.5', 'Adidas'), 'Samba OG Size 10.5');
+ equal(parse.modelOf('Air Max 90 - Men\'s', 'Nike'), 'Air Max 90');
+ equal(parse.modelOf('990v6', 'New Balance'), '990v6');
+ });
+
+ await check('colourwayOf: quoted in the title, or the source hint', () => {
+ equal(parse.colourwayOf('Air Max 90 "Medium Olive"'), 'Medium Olive');
+ equal(parse.colourwayOf('Air Max 90', 'White/Black'), 'White/Black');
+ equal(parse.colourwayOf('Air Max 90'), null);
+ });
+
+ await check('sizeOf and cleanSize: only a size the source names', () => {
+ equal(parse.sizeOf('Nike Air Max 90 Size 10.5'), '10.5');
+ equal(parse.sizeOf('Adidas Samba US 9'), '9');
+ equal(parse.sizeOf('Nike Air Max 90'), null);
+ equal(parse.sizeOf('Nike Air Max 90 - 10.5'), null, 'a bare number is not a size');
+ equal(parse.cleanSize('10.5'), '10.5');
+ equal(parse.cleanSize('10.5c'), '10.5C');
+ equal(parse.cleanSize('5y'), '5Y');
+ });
+
+ await check('conditionOf: new, used, deadstock or not stated', () => {
+ equal(parse.conditionOf('New with box'), 'new');
+ equal(parse.conditionOf('pre-owned'), 'used');
+ equal(parse.conditionOf('Deadstock'), 'deadstock');
+ equal(parse.conditionOf(''), null);
+ equal(parse.conditionOf('refurbished'), null, 'never guess a condition');
+ });
+
+ await check('amountOf and allIn: shipping only when stated', () => {
+ equal(parse.amountOf('$1,299.00'), 1299);
+ equal(parse.amountOf('89.95'), 89.95);
+ equal(parse.amountOf(''), null);
+ equal(parse.allIn(100, 5.99), { amount: 105.99, shippingKnown: true, shipping: 5.99 });
+ equal(parse.allIn(100, null), { amount: 100, shippingKnown: false, shipping: null });
+ equal(parse.allIn(null, 5), { amount: null, shippingKnown: false, shipping: null });
+ });
+
+ await check('slugFor: the page path, bounded', () => {
+ equal(parse.slugFor('Nike', 'Air Max 90'), 'nike-air-max-90');
+ equal(parse.slugFor('New Balance', '990v6'), 'new-balance-990v6');
+ assert(parse.slugFor('A'.repeat(80), 'B'.repeat(80)).length <= 80, 'slug bounded');
+ assert(/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(parse.slugFor('Dr. Martens', '1460 Boot')), 'slug shape');
+ });
+
+ await check('dayOf and isStale: UTC day and the stale window', () => {
+ equal(parse.dayOf('2026-10-31T23:59:59Z'), '2026-10-31');
+ equal(parse.dayOf('not a date'), null);
+ const now = Date.parse('2026-10-31T12:00:00Z');
+ assert(parse.isStale('2026-10-29T11:00:00Z', now, 48), '49 hours is stale');
+ assert(!parse.isStale('2026-10-30T12:00:00Z', now, 48), '24 hours is fresh');
+ assert(!parse.isStale('not a date', now, 48), 'an unknown time is not stale');
+ });
+
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+#!/usr/bin/env node
+/**
+ * Runs every test/*.test.js in its own process and fails if any fails. They use temp PGlite databases, an
+ * in-process mock of OpenVibe.Network, and in-process Chat/Live/Video fixtures; none needs the network.
+ *
+ * npm test # everything
+ * npm test -- importer # only files whose name contains one of the words
+ * npm test -- --strict # a skipped test fails the run too
+ */
+'use strict';
+const { run } = require('openvibe-shared/test-runner');
+
+// VERBOSE=1 also prints every file's output, then the summary again.
+run({ dir: __dirname, timeoutMs: 90000, pad: 34, parallel: 1 }).then((r) => {
+ if (process.env.VERBOSE) {
+ for (const t of r.results) console.log(`\n── ${t.file} ──\n${t.output.trimEnd()}`);
+ console.log(`\n${r.summary}`);
+ }
+ process.exit(r.exitCode);
+}, (err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,150 @@
@@ -0,0 +1,150 @@
+'use strict';
+/**
+ * Sign-in with PKCE S256 (client id shoes, cookies shoes_at/shoes_rt/shoes_oauth), the session-token rules (a
+ * FedCM assertion or an app token is not a session), the open-redirect guard, truthful readiness, loopback
+ * metrics, pages without JavaScript, and cross-site sign-out.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, check, done } = require('./helpers/boot');
+const { sanitizeNext } = require('../server/auth/sso');
+
+(async () => {
+ const t = await boot();
+ const user = t.network.addUser('kim');
+
+ await check('login redirects to Network with state and an S256 code challenge, client id shoes', async () => {
+ const r = await t.get('/auth/login?next=/shoe/nike-air-max-90');
+ assert.strictEqual(r.status, 302);
+ const loc = new URL(r.headers.get('location'));
+ assert.strictEqual(loc.origin + loc.pathname, `${t.network.url}/oauth/authorize`);
+ assert.strictEqual(loc.searchParams.get('client_id'), 'shoes');
+ assert.strictEqual(loc.searchParams.get('code_challenge_method'), 'S256');
+ assert.match(loc.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);
+ assert.match(loc.searchParams.get('state'), /^[0-9a-f]{32}$/);
+ assert.match(r.headers.get('set-cookie'), /shoes_oauth=.*Path=\/auth.*HttpOnly/i);
+ });
+
+ await check('callback exchanges the code with the matching verifier and sets httpOnly session cookies', async () => {
+ const login = await t.get('/auth/login?next=/shoe/nike-air-max-90');
+ const loc = new URL(login.headers.get('location'));
+ const flowCookie = login.headers.get('set-cookie').split(';')[0];
+ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
+ const r = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });
+ assert.strictEqual(r.status, 302, r.text);
+ assert.strictEqual(r.headers.get('location'), '/shoe/nike-air-max-90');
+ const set = r.headers.get('set-cookie');
+ assert.match(set, /shoes_at=[^;]+;.*HttpOnly/i);
+ assert.match(set, /shoes_rt=[^;]+;.*HttpOnly/i);
+ assert.match(set, /shoes_oauth=;/);
+ const at = set.match(/shoes_at=([^;]+)/)[1];
+ const me = await t.get('/auth/me', { cookie: `shoes_at=${at}` });
+ assert.strictEqual(me.json().user.username, 'kim');
+ assert.ok(!me.text.includes(at), '/auth/me never returns the token');
+ });
+
+ await check('a wrong state, a verifier that does not match, or no flow cookie is refused', async () => {
+ const login = await t.get('/auth/login');
+ const loc = new URL(login.headers.get('location'));
+ const flowCookie = login.headers.get('set-cookie').split(';')[0];
+ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
+ const bad = await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: flowCookie });
+ assert.strictEqual(bad.status, 400);
+ const other = t.network.issueCode(user, crypto.createHash('sha256').update('another-verifier').digest('base64url'));
+ const mismatch = await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });
+ assert.strictEqual(mismatch.status, 400);
+ const noCookie = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`);
+ assert.strictEqual(noCookie.status, 400);
+ });
+
+ await check('next= only accepts same-site paths', async () => {
+ const r = await t.get('/auth/login?next=//evil.example/x');
+ const flow = decodeURIComponent(r.headers.get('set-cookie').match(/shoes_oauth=([^;]+)/)[1]);
+ assert.strictEqual(JSON.parse(flow).next, '/');
+ const config = { baseUrl: 'https://open.shoes' };
+ assert.strictEqual(sanitizeNext('/shoe/nike-air-max-90?x=1', config), '/shoe/nike-air-max-90?x=1');
+ assert.strictEqual(sanitizeNext('https://open.shoes/drops?y=1', config), '/drops?y=1');
+ for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://open.shoes.evil.com/x']) assert.strictEqual(sanitizeNext(v, config), '/', v);
+ });
+
+ await check('readiness is truthful: db required; JWKS, the OAuth client and Valkey optional', async () => {
+ const r = await t.get('/api/ready');
+ const b = r.json();
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(b.checks.db.status, 'ok');
+ assert.strictEqual(b.checks.db.required, true);
+ assert.strictEqual(b.checks.network_jwks.status, 'ok');
+ assert.strictEqual(b.checks.network_jwks.required, false);
+ assert.strictEqual(b.checks.oauth_client.status, 'ok');
+ assert.strictEqual(b.checks.oauth_client.required, false);
+ assert.ok('valkey' in b.checks);
+ });
+
+ await check('/release.json and /metrics (loopback only)', async () => {
+ const rel = await t.get('/release.json');
+ assert.strictEqual(rel.status, 200);
+ assert.strictEqual(rel.json().service, 'shoes');
+ assert.deepStrictEqual(require('openvibe-contracts').validate('registry.release-manifest@1', rel.json()).errors, []);
+ const m = await t.get('/metrics');
+ assert.strictEqual(m.status, 200);
+ assert.match(m.text, /http_requests_total|release_info/);
+ const proxied = await t.get('/metrics', { headers: { 'x-forwarded-for': '203.0.113.9' } });
+ assert.notStrictEqual(proxied.status, 200, 'a proxied request is not loopback');
+ });
+
+ await check('public pages are complete without JavaScript', async () => {
+ for (const p of ['/', '/shoes', '/drops', '/developers']) {
+ const r = await t.get(p);
+ assert.strictEqual(r.status, 200, p);
+ assert.match(r.text, /<noscript>/);
+ assert.match(r.text, /<footer id="ov-footer"/);
+ assert.match(r.text, /<main id="main"/);
+ }
+ });
+
+ await check('the API answers /status and unknown routes are problem+json 404', async () => {
+ const status = await t.get('/api/v1/status');
+ assert.strictEqual(status.status, 200);
+ assert.strictEqual(status.json().service, 'shoes');
+ const gone = await t.get('/api/v1/nope');
+ assert.strictEqual(gone.status, 404);
+ assert.match(gone.headers.get('content-type'), /application\/problem\+json/);
+ assert.strictEqual(gone.json().code, 'route.not_found');
+ });
+
+ await check('a FedCM assertion and an app token are not sessions', async () => {
+ const now = Math.floor(Date.now() / 1000);
+ const fedcm = t.network.signJwt({ iss: t.network.issuer(), sub: user.id, id: user.id, subject_id: user.subject, username: user.username, display_name: user.username, nonce: null, typ: 'fedcm', jti: 'x', aud: 'https://tenant.openvibe.host', iat: now, exp: now + 300 });
+ assert.strictEqual((await t.get('/auth/me', { cookie: `shoes_at=${fedcm}` })).status, 401);
+ const page = await t.get('/', { cookie: `shoes_at=${fedcm}` });
+ assert.ok(!page.text.includes('Signed in as <strong>kim'));
+ const app = t.network.signJwt({ iss: t.network.issuer(), sub: 'app:app_01JABCDEFGHJKMNPQRSTVWXYZ0', actor_type: 'app', aud: ['openvibe.network'], cap: ['x.y.z'], subject_id: user.subject, iat: now, exp: now + 300 });
+ assert.strictEqual((await t.get('/auth/me', { cookie: `shoes_at=${app}` })).status, 401);
+ });
+
+ await check('/auth/me: a guest is signed out (200 { user: null }); a failing session cookie is 401', async () => {
+ const guest = await t.get('/auth/me');
+ assert.strictEqual(guest.status, 200);
+ assert.deepStrictEqual(JSON.parse(guest.text), { user: null });
+ assert.strictEqual(guest.headers.get('cache-control'), 'private, no-store');
+ assert.strictEqual((await t.get('/auth/me', { cookie: 'shoes_at=garbage' })).status, 401);
+ assert.strictEqual((await t.get('/auth/me', { cookie: 'shoes_rt=stale' })).status, 401);
+ assert.strictEqual((await t.get('/auth/me', { as: user })).status, 200);
+ });
+
+ await check('another site cannot sign a person out; this site can', async () => {
+ const cross = await t.get('/auth/logout', { as: user, headers: { 'sec-fetch-site': 'cross-site' } });
+ assert.strictEqual(cross.status, 200);
+ assert.ok(!/shoes_at=;|shoes_at=deleted/i.test(String(cross.headers.get('set-cookie') || '')));
+ assert.match(cross.text, /method="post"/);
+ const crossPost = await t.get('/auth/logout', { as: user, method: 'POST', headers: { 'sec-fetch-site': 'cross-site' } });
+ assert.strictEqual(crossPost.status, 403);
+ const same = await t.get('/auth/logout?next=/shoes', { as: user, headers: { 'sec-fetch-site': 'same-origin' } });
+ assert.strictEqual(same.status, 303);
+ assert.strictEqual(same.headers.get('location'), '/shoes');
+ assert.match(String(same.headers.get('set-cookie') || ''), /shoes_at=;/);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+'use strict';
+
+/**
+ * The page's small pure pieces: the release variants become one row, money is never a made-up zero, and a price
+ * history with one point is still drawn as one point (no fabricated curve).
+ */
+const { check, done, assert, equal } = require('./helpers/check');
+const views = require('../server/views');
+
+(async () => {
+ await check('groupDrops: one row per model, colourway and release moment', () => {
+ const base = { brand: 'Jordan', model: '6 Retro', colourway: 'White Infrared', release_at: '2026-11-07T15:00:00.000Z', source_name: 'Nike SNKRS', source_url: 'https://www.nike.com/launch' };
+ const out = views.groupDrops([
+ { ...base, id: 'a', price: 215, sizes: ['9'] },
+ { ...base, id: 'b', price: 155, sizes: ['10', '9'] },
+ { ...base, id: 'c', colourway: 'Green Screen', price: 235, sizes: [] },
+ ]);
+ equal(out.length, 2);
+ equal(out[0].variants, 2);
+ equal(out[0].price, 155, 'the lowest price of the variants');
+ equal(out[0].sizes, ['9', '10']);
+ equal(out[1].colourway, 'Green Screen');
+ });
+
+ await check('money: a number, or a dash, never a zero', () => {
+ equal(views.money(89.95), '$89.95');
+ equal(views.money(1299), '$1,299.00');
+ equal(views.money(null), '—');
+ equal(views.money('not a number'), '—');
+ });
+
+ await check('historyChart: one real point is a dot, no curve invented', () => {
+ const one = views.historyChart([{ day: '2026-10-31', price: 89.95 }]);
+ assert(one.includes('<svg'), 'the chart');
+ assert(one.includes('2026-10-31 → 2026-10-31'), 'one day');
+ assert(!one.includes('No snapshot yet'), 'a point is a point');
+ assert(views.historyChart([]).includes('No snapshot yet'), 'nothing yet says so');
+ });
+
+ await check('curveBars: an empty curve says not known yet', () => {
+ assert(views.curveBars({ points: [], median: null }).includes('not stated'), 'the honest note');
+ const bars = views.curveBars({ points: [{ size: '9', min_price: 90, vs_median_percent: -5 }], median: 95 });
+ assert(bars.includes('sh-curve') && bars.includes('9'), 'a real bar');
+ assert(bars.includes('−5.0%'), 'the comparison');
+ });
+
+ done();
+})();