openvibers/openvibe.report Commits
OpenVibe.Report v1: every number on OpenVibe, honest. The network's open numbers, The Rundown (what streamers talked about) and each stream's after-show report, with an open JSON API; stateless over Network's open stats and Live's Rundown and recaps
Changes
nnew file mode 100644@@@ -0,0 +1,21 @@++# OpenVibe.Report — environment (production: /etc/openvibe/report.env). Names only; never commit values.++++NODE_ENV=production++PORT=5080++HOST=127.0.0.1++BASE_URL=https://openvibe.report++TRUST_PROXY=2++++# OpenVibe.Network's open stats (GET /api/v1/stats?days=7|30|90). Both sources serve these read APIs on loopback;++# the defaults are the internalOrigin values in openvibe-contracts' manifests/services/{network,live}.json.++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++++# OpenVibe.Live's Rundown and after-show reports (GET /api/rundown/*, /api/recap/*).++OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000++++# Optional: the per-address API limit (default 120 a minute) and the readiness probe timeout (default 2500 ms).++#REPORT_API_RATE_LIMIT_PER_MIN=120++#REPORT_READY_TIMEOUT_MS=2500++++# Optional: the deployed commit for /release.json. Without it the release is read from git in the checkout.++#RELEASE_COMMIT=nnew file mode 100644@@@ -0,0 +1,16 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++jobs:++ # install, node --check and npm test: the shared test job (Track Q). Report has no database and no services to++ # start. The contracts check is skipped until a report service manifest is registered in openvibe-contracts.++ test:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176++ with:++ syntax-dirs: server test public++ test: npm test++ # 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/++.npm-cache/++.ref/++.env++.env.*++!.env.example++*.log++*.pem++*.key++coverage/++.DS_Storennew file mode 100644@@@ -0,0 +1,30 @@++# Changelog++++What changed in OpenVibe.Report, newest first. Each site also publishes its patch notes at /updates on++openvibe.network.++++## 0.1.0 — 2026-10-28++++The service skeleton: a stateless front for the network's open numbers, The Rundown and every stream's after-show++report.++++- **Two read clients** (`server/data/network.js`, `server/data/live.js`): OpenVibe.Network's open stats and++ OpenVibe.Live's Rundown and recap APIs, with injectable fetch, 8 s timeouts, an in-memory cache (1000 entries,++ oldest out; 2 to 10 minutes per answer) and one short log line on failure. A failure is null, never a throw.++ Slugs, stream ids and usernames are validated before a source request; a bad parameter is a 404 without one.++- **Pages** (`server/http/pages.js`, `server/views/index.js`): `/`, `/stats`, `/rundown`, `/rundown/t/:slug`,++ `/rundown/e/:streamId`, `/rundown/s/:username`, `/after-show/:streamId` and `/c/:username`. The views are plain++ placeholders with every dynamic value escaped; the main session designs the real pages. A missing source answer++ is the 404 view.++- **JSON API** (`server/http/api.js`): `/api/v1/stats`, `/api/v1/rundown`, `/api/v1/rundown/topics`,++ `/api/v1/rundown/t/:slug`, `/api/v1/after-show/:streamId` and `/api/v1/c/:username` answer the source's document++ as it came, CORS-open and public for two minutes; 120 requests a minute per address; problem+json 404s.++- **Plumbing**: helmet with the same CSP as OpenVibe.Help, `/api/health`, `/api/ready` (Network and Live are++ optional, so a dead source is degraded and still 200), `/release.json`, `/release-metrics`, `/metrics` on++ loopback, `/shared/*`, `/css` and `/js` (immutable only with the matching `?v=`), `robots.txt`, `sitemap.xml`++ (the home, `/stats`, `/rundown` and every topic), `llms.txt`, and the SDK's graceful stop.++- **Deploy**: `deploy/systemd/openvibe-report.service` on 127.0.0.1:5080 and++ `deploy/nginx/openvibe.report.conf` (www → apex, Let's Encrypt certs, GET-only `/api/`), plus `STATUS.json`,++ `.env.example` and the shared CI workflow.++- **Tests** (`test/`): the clients' cache, expiry, failure and parameter rules; every page and its 404s; the API++ shapes, CORS, cache and rate limit; robots/sitemap/llms; health, readiness and the static cache.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,125 @@++# OpenVibe.Report++++> Every number on OpenVibe, honest.++++**Status:** v0.1.0 service skeleton (see [STATUS.json](STATUS.json)). The routes, the API and the plumbing are++complete; the views are plain placeholders the main session will design.++**Domain:** `openvibe.report` · **Port:** 5080 · **Service id:** `report` · **Env prefix:** `OV_` / `REPORT_`++**License:** AGPL-3.0 (same as every OpenVibe service).++++The Report room of the OpenVibe network: the open numbers every site publishes, The Rundown of what streamers talked++about, and each stream's after-show report.++++## Purpose++++OpenVibe.Report is a stateless read-only front. It has no database, no sign-in and no events. It asks two loopback++APIs for their public answers, holds each answer in this process for a few minutes, and renders it:++++| Source | Read | Used for |++|---|---|---|++| **OpenVibe.Network** | `GET /api/v1/stats?days=7\|30\|90` | `/`, `/stats`, `GET /api/v1/stats` |++| **OpenVibe.Live** — The Rundown | `GET /api/rundown/`, `/api/rundown/topics`, `/api/rundown/t/:slug`, `/api/rundown/e/:streamId`, `/api/rundown/s/:username` | `/rundown`, `/rundown/t/:slug`, `/rundown/e/:streamId`, `/rundown/s/:username` |++| **OpenVibe.Live** — after-show reports | `GET /api/recap/:streamId`, `GET /api/recap/channel/:username` | `/after-show/:streamId`, `/c/:username` |++++A source that does not answer is null, after one short log line; the page (or API route) becomes a 404. The service++never invents a zero and never throws a source error at the reader.++++## Does not own++++- **The numbers** — OpenVibe.Network's open stats. Report quotes them.++- **The Rundown and the after-show reports** — OpenVibe.Live. Report quotes them, including their counts and text.++- **Identity, sessions and writes** — Report has none. It does not read cookies and has no POST routes.++- **The Shared Frame** — `openvibe-shared` (navbar, footer, theme-loader, static files), served from this site's own++ pinned copy at `/shared/*`.++++## Pages++++All server-rendered placeholders in `server/views/index.js`; every dynamic value goes through `esc()`.++++| Page | What it shows |++|---|---|++| `/` | the 7-day open numbers, the Rundown's counts and lead, and the trending topics |++| `/stats?days=7\|30\|90` | the network totals and a first look at every site (anything else is 30) |++| `/rundown` | on air, the lead, the latest streams and every topic |++| `/rundown/t/:slug` | one topic, its takes and related topics |++| `/rundown/e/:streamId` | one stream as an episode, with its segments |++| `/rundown/s/:username` | one streamer's episodes and topics |++| `/after-show/:streamId` | one after-show report |++| `/c/:username` | one channel's latest after-show reports |++++A bad path parameter (slug, stream id, username) or a source that answered nothing renders `views.notFound()` with++status 404. Pages are `Cache-Control: public, max-age=120`.++++## API++++All under `/api/v1`, CORS `*`, `Cache-Control: public, max-age=120`, 120 requests a minute per address, and the++source's answer as it came:++++```++GET /api/v1/stats?days=7|30|90++GET /api/v1/rundown++GET /api/v1/rundown/topics++GET /api/v1/rundown/t/:slug++GET /api/v1/after-show/:streamId++GET /api/v1/c/:username++```++++A bad parameter or a missing source answer is a 404 `application/problem+json` body built with++`openvibe-contracts` `http.sendProblem`. The API is read-only; other methods get 405.++++## Crawl artifacts++++- `/robots.txt` — the public pages are crawlable; `/api/` is not; the sitemap is named.++- `/sitemap.xml` — `/`, `/stats`, `/rundown` and every `/rundown/t/<slug>` from the Rundown's topics (the three++ static pages remain when the topics source is down). `lastmod` comes from `STATUS.json`.++- `/llms.txt` — the site, its pages and its machine-readable answers.++++## Configuration++++Every value comes from the environment (production: `/etc/openvibe/report.env`). See [.env.example](.env.example).++++| Name | Default | Meaning |++|---|---|---|++| `NODE_ENV` | `development` | production switches the base URL and is required for a real deploy |++| `PORT` | `5080` | the loopback port nginx proxies to |++| `HOST` | `127.0.0.1` | listen address |++| `BASE_URL` | `https://openvibe.report` in production, otherwise `http://localhost:5080` | canonical links, sitemap and llms.txt |++| `TRUST_PROXY` | `2` | Express trust proxy (the app sets no cookies, but the per-address limit uses the client address) |++| `OV_NETWORK_INTERNAL_URL` | `http://127.0.0.1:4000` | OpenVibe.Network's loopback origin (its manifest's `internalOrigin`) |++| `OV_LIVE_INTERNAL_URL` | `http://127.0.0.1:3000` | OpenVibe.Live's loopback origin (its manifest's `internalOrigin`) |++| `REPORT_API_RATE_LIMIT_PER_MIN` | `120` | per-address API requests a minute |++| `REPORT_READY_TIMEOUT_MS` | `2500` | how long a readiness probe may take |++| `RELEASE_COMMIT` | unset | `/release.json`'s release; otherwise read from git in the checkout |++++## Run and test++++```sh++npm install++npm start # http://127.0.0.1:5080++npm test # every test/*.test.js, in its own process, with both sources stubbed++```++++The tests need no database, no network and no running OpenVibe site: `test/helpers/boot.js` supplies a URL-aware++stub for both sources. They cover the clients' cache hit, expiry, failure and parameter rules; every page route and++its 404s; the API shapes, CORS, cache and rate limit; robots/sitemap/llms; and health/readiness/static caching.++++`node --check` is run over `server/`, `test/` and `public/` by CI (`.github/workflows/ci.yml`, the shared++OpenVibe.Shared test workflow, no database service).++++## Deploy++++The reference files are in `deploy/`:++++- `deploy/systemd/openvibe-report.service` — `User=ubuntu`, `WorkingDirectory=/opt/openvibe.report`,++ `EnvironmentFile=/etc/openvibe/report.env`, `PORT=5080`, `SIGTERM` with a 10 s drain.++- `deploy/nginx/openvibe.report.conf` — HTTP → HTTPS, `www` → apex 301, Let's Encrypt certificates under++ `/etc/letsencrypt/live/openvibe.report/`, proxy to `127.0.0.1:5080`, the same security headers as Help's vhost,++ and `/api/` GET-only with a coarse rate limit.++++`/api/ready` reports `network` and `live` as optional checks: one or both down is `status: "degraded"` with HTTP 200,++so a stateless front is never marked not-ready just because a source is unavailable.++++## Security++++Every source text is escaped before it reaches HTML. Path parameters are checked against fixed patterns before any++request. Report holds no accounts, no cookies, no database and no write routes. See [SECURITY.md](SECURITY.md).nnew file mode 100644@@@ -0,0 +1,25 @@++# 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 (openvibe.live, openvibe.network, 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.++++For OpenVibe.Report specifically: the service reads two internal APIs and has no database, no accounts and no++writes. The interesting surface is the path parameters it forwards (validated before a request), the HTML it renders++(every source text is escaped by `server/views/index.js`) and the two source origins it is configured with.++++## Supported versions++++Only the current `main` branch, which is what runs in production, receives fixes.nnew file mode 100644@@@ -0,0 +1,51 @@++{++ "repository": "OpenVibers/OpenVibe.Report",++ "version": "0.1.0",++ "stage": "skeleton",++ "code": true,++ "wave": "W21",++ "plan": "OpenVibe.Report: a stateless front for the network's open numbers, The Rundown and every stream's after-show report",++ "runtime": "node 22, express 4, no database, no sessions, no events",++ "port": "5080",++ "domain": "openvibe.report",++ "contracts": "openvibe-contracts v0.225.0",++ "sdk": "openvibe-sdk v0.70.0",++ "shared": "openvibe-shared v4.29.1",++ "packages": [++ "openvibe-contracts v0.225.0",++ "openvibe-sdk v0.70.0",++ "openvibe-shared v4.29.1"++ ],++ "summary": "The Report room of the OpenVibe network. A stateless front with no database, no sign-in and no events: it reads OpenVibe.Network's open stats (GET /api/v1/stats?days=7|30|90) and OpenVibe.Live's Rundown (GET /api/rundown/, /topics, /t/:slug, /e/:streamId, /s/:username) and after-show reports (GET /api/recap/:streamId, /api/recap/channel/:username) over loopback, caches each answer for 2 to 10 minutes in this process, and shows it as the source answered it. Network and Live failures answer null and a page or API 404, never a made-up zero. The pages are plain placeholders the main session will design; the routes, the JSON API, the crawl artifacts, readiness (degraded, never 503, when a source is down) and the deploy files are complete.",++ "works": [++ "The three sources read through two clients with injectable fetch, 8 s timeouts and an in-memory cache (stats 5 min, Rundown home 2 min, topics 5 min, a topic 5 min, an episode 5 min, an after-show report 10 min, a channel's reports 5 min; 1000 entries, oldest out)",++ "Path parameters validated before a request: slug ^[a-z0-9-]{1,80}$, a positive integer stream id, username ^[A-Za-z0-9_]{1,32}$",++ "Pages: /, /stats?days=7|30|90, /rundown, /rundown/t/:slug, /rundown/e/:streamId, /rundown/s/:username, /after-show/:streamId, /c/:username; a bad parameter or a missing source answer is the 404 view",++ "JSON API: /api/v1/stats, /rundown, /rundown/topics, /rundown/t/:slug, /after-show/:streamId, /c/:username — the source's answer, CORS *, public for two minutes, 120 requests a minute per address, problem+json 404s",++ "Plumbing: helmet with Help's CSP, /api/health, /api/ready (Network and Live optional → degraded, never 503), /release.json, /release-metrics, /metrics on loopback, /shared/*, /css and /js (immutable only with the matching ?v=), robots.txt, sitemap.xml, llms.txt, graceful stop",++ "Deploy files: systemd unit openvibe-report on 127.0.0.1:5080 and an nginx vhost for openvibe.report + www (apex redirect, certs, proxy, GET-only API)",++ "Tests: the clients' cache, expiry, failure and validation; every page 200 and its 404s; the API shapes, CORS and cache; robots/sitemap/llms; health, readiness and static cache"++ ],++ "notYet": [++ "The real page designs: v1 ships plain placeholder views, as the task asked; the main session designs the room itself",++ "A report manifest in openvibe-contracts: this skeleton does not register one, and CI skips the contracts-service check until it lands",++ "Any write path: Report has no accounts, no reactions and no saved views; it is a read-only front"++ ],++ "capabilitiesRegistered": false,++ "serviceManifestRegistered": false,++ "deployed": false,++ "placeholderInSites": false,++ "features": [++ "open network stats",++ "The Rundown",++ "after-show reports",++ "source clients with caches",++ "server-rendered placeholder pages",++ "JSON API",++ "crawl artifacts",++ "readiness",++ "deploy files"++ ],++ "deployment": "planned: openvibe-ovh, unit openvibe-report.service on 127.0.0.1:5080 (git checkout /opt/openvibe.report, env /etc/openvibe/report.env), public at https://openvibe.report",++ "updated": "2026-10-28"++}nnew file mode 100644@@@ -0,0 +1,91 @@++## ═══════════════════════════════════════════════════════════════++## openvibe.report — Nginx configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/openvibe.report.conf (ov-vhost-install) — keep them in sync and reload++## nginx after changing it. The Node app (port 5080, systemd unit openvibe-report) renders every page and serves++## the API; nginx terminates TLS, redirects www → apex, rate-limits the API, and compresses text.++##++## No proxy_cache: the app decides cacheability per response (public for two minutes on pages and API, an hour on++## the crawl artifacts, immutable only for a content-addressed asset). The API is GET-only.++## ═══════════════════════════════════════════════════════════════++++limit_req_zone $binary_remote_addr zone=report_api:10m rate=5r/s;++++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.report www.openvibe.report;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.report$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.openvibe.report;++++ ssl_certificate /etc/letsencrypt/live/openvibe.report/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.report/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ return 301 https://openvibe.report$request_uri;++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.report;++++ ssl_certificate /etc/letsencrypt/live/openvibe.report/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.report/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/openvibe.report.access.log;++ error_log /var/log/nginx/openvibe.report.error.log;++++ client_max_body_size 64k;++++ 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;++++ # ── API: GET and HEAD only, with the app's 120/min limit backed by a coarse nginx one ──++ location /api/ {++ add_header Allow "GET, HEAD" always;++ if ($request_method !~ ^(GET|HEAD)$) { return 405; }++ limit_req zone=report_api burst=20 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5080;++ }++++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5080/metrics) ──++ location ~* ^/metrics(/|$) { return 404; }++++ # ── Internal delivery would be loopback-only; Report has none, so refuse it from outside ──++ location ~* ^/internal(/|$) { return 404; }++++ # ── Pages, crawl artifacts, static assets, release metadata ──++ location / {++ proxy_pass http://127.0.0.1:5080;++ }++++ location ~ /\.(?!well-known) {++ deny all;++ }++}nnew file mode 100644@@@ -0,0 +1,29 @@++[Unit]++Description=OpenVibe.Report — Every number on OpenVibe, honest.++# Network and Live should be up first, but Report degrades honestly without either: the pages and the API answer++# what they can and /api/ready says degraded (never 503), because neither source is required to serve a page.++After=network.target openvibe-network.service openvibe-live.service++Wants=openvibe-network.service openvibe-live.service++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/openvibe.report++EnvironmentFile=/etc/openvibe/report.env++Environment=NODE_ENV=production++Environment=PORT=5080++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.report/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++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,1129 @@++{++ "name": "openvibe-report",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-report",++ "version": "0.1.0",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "express-rate-limit": "^7.5.0",++ "helmet": "^8.1.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.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.29.1"++ },++ "engines": {++ "node": ">=22.0.0"++ }++ },++ "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/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-signature": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",++ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",++ "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/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/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.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.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.225.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.0",++ "integrity": "sha512-msLaRYa2Byyabnf/+L3aRPwkx8MI3zWbofFhwupq+RyBCdgbA4gLlkPsJu9nK/1UTCWdNdSTfWXW5pZY1/1zbQ==",++ "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.29.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.29.1",++ "integrity": "sha512-wQVzLibhXmPJTJ3El5q8G6cAuykeP9P07sPPZnXsyiOc4OdA8/l4tKaQT/f97Dzjsc9WFUNzRAhnbl0W6nLixg==",++ "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/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/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/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"++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,29 @@++{++ "name": "openvibe-report",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe.Report — Every number on OpenVibe, honest.",++ "main": "server/index.js",++ "scripts": {++ "start": "node server/index.js",++ "dev": "NODE_ENV=development node server/index.js",++ "test": "node test/run.js"++ },++ "keywords": [++ "openvibe"++ ],++ "author": "OpenVibers",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "express-rate-limit": "^7.5.0",++ "helmet": "^8.1.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.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.29.1"++ },++ "engines": {++ "node": ">=22.0.0"++ }++}nnew file mode 100644@@@ -0,0 +1,133 @@++/* OpenVibe.Report — the room's own pieces. Colours come from the theme (--bg-*, --text-*, --border) and the room's++ accent (.ov-room in openvibe-shared room.css: --room-accent, --room-strong, --room-fill, --room-on), so every theme,++ light or dark, wears it. Phone first: tables become stacked rows under 700 px; nothing scrolls sideways. */++*, *::before, *::after { box-sizing: border-box; }++html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font-family: '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; }++.rp-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }++.rp a:not([class]) { color: var(--room-strong); }++.rp h2 { margin: 0 0 12px; font-size: 1.2rem; font-weight: 800; letter-spacing: -.01em; }++.rp section { margin-top: clamp(28px, 4vw, 44px); }++.rp-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }++.rp-head-row h2 { margin-bottom: 8px; }++.rp-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; white-space: nowrap; }++:where(.rp) .ovk-i { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }++.rp-more .ovk-i { width: 1em; height: 1em; }++.rp-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }++.rp-kicker a { text-decoration: none; }++.rp-page-h { padding-top: clamp(20px, 4vw, 40px); }++.rp-page-h h1 { margin: 0; font-size: clamp(1.8rem, 4.6vw, 2.8rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.08; text-wrap: balance; overflow-wrap: anywhere; }++.rp-lede { margin: 10px 0 0; max-width: 760px; color: var(--text-secondary, #96a7c2); font-size: 1.05rem; line-height: 1.55; }++.rp-summary { margin: 14px 0 0; max-width: 780px; line-height: 1.65; }++.rp-fine { margin: 0 0 14px; max-width: 800px; color: var(--text-muted, #7386a3); font-size: .88rem; line-height: 1.55; }++.rp-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin: 10px 0 0; color: var(--text-muted, #7386a3); font-size: .86rem; }++.rp-meta a { color: var(--text-secondary, #96a7c2); }++.rp-meta .ovk-mark { vertical-align: middle; }++.rp-ai { display: inline-flex; align-items: center; padding: 1px 8px; border-radius: 999px; font-size: .72rem; font-weight: 800; letter-spacing: .02em; color: var(--room-strong); background: color-mix(in srgb, var(--room-accent) 13%, transparent); }++.rp-pill { display: inline-flex; align-items: center; gap: 2px; padding: 1px 7px; border-radius: 999px; font-size: .72rem; font-weight: 800; white-space: nowrap; }++.rp-pill.is-up { color: var(--success-text, #22c55e); background: color-mix(in srgb, var(--success, #22c55e) 14%, transparent); }++.rp-pill.is-down { color: var(--danger-text, #f87171); background: color-mix(in srgb, var(--danger, #f87171) 14%, transparent); }++.rp-pill.is-flat { color: var(--text-muted, #7386a3); background: color-mix(in srgb, var(--text-muted, #7386a3) 14%, transparent); }++.rp-tabs { display: inline-flex; gap: 4px; margin-top: 16px; padding: 4px; border-radius: 999px; background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); }++.rp-tabs a { padding: 7px 14px; border-radius: 999px; color: var(--text-secondary, #96a7c2); text-decoration: none; font-size: .86rem; font-weight: 700; }++.rp-tabs a[aria-current] { background: var(--room-fill); color: var(--room-on); }++.rp-counts { display: flex; flex-wrap: wrap; gap: 6px 20px; margin: 14px 0 0; color: var(--text-secondary, #96a7c2); }++.rp-counts b { color: var(--text-primary, #e6edf7); font-size: 1.15rem; font-variant-numeric: tabular-nums; }++++/* Tiles: one number each, with its window. */++.rp-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(170px, 44%), 1fr)); gap: 12px; margin-top: 22px; }++.rp-tile { position: relative; display: flex; flex-direction: column; gap: 4px; min-height: 104px; padding: 14px 16px; border-radius: 16px; background: linear-gradient(160deg, color-mix(in srgb, var(--room-accent) 10%, transparent), transparent 70%), var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; overflow: hidden; }++.rp-tile-l { font-size: .74rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.rp-tile-v { font-size: 1.8rem; font-weight: 850; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }++.rp-tile-v small { font-size: .9rem; color: var(--text-muted, #7386a3); font-weight: 700; }++.rp-tile-s { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: .82rem; color: var(--text-secondary, #96a7c2); position: relative; z-index: 1; }++.rp-tile .rp-spark { position: absolute; right: 10px; bottom: 10px; width: 46%; height: 34px; opacity: .9; }++.rp-spark-line { fill: none; stroke: var(--room-accent); stroke-width: 2.2; vector-effect: non-scaling-stroke; stroke-linejoin: round; }++.rp-spark-area { fill: var(--room-accent); opacity: .14; }++.rp-minis { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; }++.rp-minis > div { display: flex; flex-direction: column; gap: 2px; padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.rp-minis b { font-size: 1.25rem; font-variant-numeric: tabular-nums; }++.rp-minis span { font-size: .8rem; color: var(--text-muted, #7386a3); }++++/* Charts. */++.rp-bars, .rp-curve { margin: 0; padding: 14px; border-radius: 16px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.rp-bars svg { display: block; width: 100%; height: 140px; }++.rp-bars rect { fill: var(--room-accent); opacity: .85; }++.rp-bars rect:hover { opacity: 1; }++.rp-bars figcaption, .rp-curve figcaption { display: flex; justify-content: space-between; gap: 10px; margin-top: 8px; font-size: .78rem; color: var(--text-muted, #7386a3); }++.rp-curve svg { display: block; width: 100%; height: 160px; }++.rp-curve-line { fill: none; stroke: var(--room-accent); stroke-width: 2.4; vector-effect: non-scaling-stroke; stroke-linejoin: round; }++.rp-curve-area { fill: var(--room-accent); opacity: .12; }++.rp-curve-chat rect { fill: var(--text-muted, #7386a3); opacity: .45; }++.rp-legend { display: inline-flex; align-items: center; gap: 6px; }++.rp-legend i { display: inline-block; width: 12px; height: 4px; border-radius: 2px; background: var(--room-accent); }++.rp-legend i.is-c { background: var(--text-muted, #7386a3); opacity: .6; }++++/* The stats table: rows on wide screens, stacked on phones. */++.rp-table { display: grid; border: 1px solid var(--border, #1f2d47); border-radius: 16px; overflow: hidden; background: var(--bg-card, #0f1729); }++.rp-tr { display: grid; grid-template-columns: minmax(0, 1.4fr) repeat(4, minmax(0, 1fr)); gap: 12px; align-items: center; padding: 11px 16px; border-top: 1px solid var(--border, #1f2d47); font-variant-numeric: tabular-nums; }++.rp-tr:first-child { border-top: 0; }++.rp-th { font-size: .72rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #7386a3); background: color-mix(in srgb, var(--room-accent) 5%, transparent); }++.rp-site { display: flex; flex-direction: column; min-width: 0; }++.rp-site a { font-weight: 700; text-decoration: none; }++.rp-site small { color: var(--text-muted, #7386a3); font-size: .78rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++@media (max-width: 699px) {++ .rp-th { display: none; }++ .rp-tr { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 14px; }++ .rp-site { grid-column: 1 / -1; }++ .rp-tr > [data-l]::before { content: attr(data-l); display: block; font-size: .7rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++}++++/* The Rundown. */++.rp-lead { padding: clamp(18px, 3vw, 28px); border-radius: 20px; border: 1px solid color-mix(in srgb, var(--room-accent) 35%, var(--border, #1f2d47)); background: radial-gradient(120% 90% at 90% 0%, color-mix(in srgb, var(--room-accent) 14%, transparent), transparent 60%), var(--bg-card, #0f1729); }++.rp-lead h2 { font-size: clamp(1.3rem, 2.8vw, 1.8rem); line-height: 1.2; text-wrap: balance; }++.rp-lead h2 a { color: inherit; text-decoration: none; }++.rp-lead h2 a:hover { text-decoration: underline; }++.rp-lead-what { margin: 0; max-width: 820px; line-height: 1.6; color: var(--text-secondary, #96a7c2); }++.rp-lead-take { margin: 12px 0 0; max-width: 820px; padding-left: 14px; border-left: 3px solid var(--room-accent); line-height: 1.55; font-style: italic; }++.rp-topics { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr)); gap: 12px; }++.rp-topic { display: flex; flex-direction: column; gap: 6px; height: 100%; padding: 14px 16px; border-radius: 16px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); color: var(--text-primary, #e6edf7) !important; text-decoration: none; transition: border-color .18s, transform .18s; }++.rp-topic:hover, .rp-topic:focus-visible { border-color: var(--room-accent); transform: translateY(-2px); outline: none; }++.rp-topic b { font-size: 1.02rem; line-height: 1.3; }++.rp-topic-h { color: var(--text-secondary, #96a7c2); font-size: .9rem; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }++.rp-topic .rp-meta { margin-top: auto; }++.rp-kind { display: inline-flex; width: fit-content; padding: 1px 8px; border-radius: 999px; font-size: .7rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; color: var(--room-strong); background: color-mix(in srgb, var(--room-accent) 12%, transparent); text-decoration: none; }++.rp-takes { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }++.rp-take { padding: 12px 16px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.rp-take h3 { margin: 0; font-size: 1rem; line-height: 1.35; }++.rp-take h3 a { color: inherit; text-decoration: none; }++.rp-take h3 a:hover { text-decoration: underline; }++.rp-take p { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; font-size: .92rem; }++.rp-take .rp-meta { margin-top: 6px; font-size: .8rem; }++.rp-timeline { position: relative; padding-left: 18px; }++.rp-timeline::before { content: ""; position: absolute; left: 4px; top: 6px; bottom: 6px; width: 2px; background: color-mix(in srgb, var(--room-accent) 35%, transparent); border-radius: 2px; }++.rp-voices { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); gap: 10px; }++.rp-voices a { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); color: var(--text-primary, #e6edf7) !important; text-decoration: none; }++.rp-voices a:hover { border-color: var(--room-accent); }++.rp-voices span { display: flex; flex-direction: column; min-width: 0; }++.rp-voices small { color: var(--text-muted, #7386a3); font-size: .8rem; }++.rp-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }++.rp-chips a, .rp-chips span { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); color: var(--text-primary, #e6edf7); text-decoration: none; font-weight: 600; font-size: .9rem; }++.rp-chips a:hover { border-color: var(--room-accent); }++.rp-person { display: flex; align-items: center; gap: 16px; }++++/* The after-show report. */++.rp-moments { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }++.rp-moments li { display: flex; align-items: flex-start; gap: 12px; padding: 10px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.rp-moments li > span { display: flex; flex-direction: column; gap: 4px; min-width: 0; }++.rp-at { flex: none; display: inline-flex; align-items: center; gap: 4px; min-height: 32px; padding: 0 10px; border-radius: 999px; background: var(--room-fill); color: var(--room-on) !important; font-size: .78rem; font-weight: 800; text-decoration: none; font-variant-numeric: tabular-nums; }++.rp-at .ovk-i { width: .9em; height: .9em; fill: currentColor; stroke: none; }++.rp-act { display: flex; flex-wrap: wrap; gap: 8px; margin: 28px 0 0; }++.rp-btn { display: inline-flex; align-items: center; min-height: 42px; padding: 0 18px; border-radius: 999px; background: var(--room-fill); color: var(--room-on) !important; font-weight: 700; font-size: .92rem; text-decoration: none; }++.rp-btn.is-quiet { background: transparent; color: var(--room-strong) !important; border: 1px solid color-mix(in srgb, var(--room-accent) 45%, var(--border, #1f2d47)); }++.rp-grid { list-style: none; margin: 22px 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); gap: 18px; }++.rp-scene-num { display: grid; gap: 6px; }++.rp-scene-num .rp-pill { justify-self: start; }++.rp-scene-num b { font-size: 2.4rem; font-weight: 850; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }++.rp-scene-num .rp-spark { width: 100%; height: 90px; }++.rp-eps { display: grid; gap: 10px; margin: 0; }++.rp-eps > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.rp-eps dt code { font: 600 .92rem/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--room-strong); overflow-wrap: anywhere; }++.rp-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }++@media (prefers-reduced-motion: reduce) { .rp-topic { transition: none; } }nnew file mode 100644@@@ -0,0 +1,2 @@++'use strict';++/* OpenVibe.Report — placeholder script. The real pages add their own in-browser conveniences. */nnew file mode 100644@@@ -0,0 +1,147 @@++'use strict';++++/**++ * OpenVibe.Report — Express app factory. server/index.js listens; tests build their own instance with a stub++ * fetch so no test needs the network or a running site.++ *++ * /, /stats, /rundown, /rundown/t/:slug, /rundown/e/:streamId, /rundown/s/:username,++ * /after-show/:streamId, /c/:username the pages (http/pages.js)++ * /api/v1/* the JSON API (http/api.js)++ * /robots.txt, /sitemap.xml, /llms.txt crawl artifacts (http/discovery.js)++ * /api/health, /api/ready, /release.json, /release-metrics the plumbing (loopback scrape for /metrics)++ *++ * The service is stateless: no database, no sessions and no events. The two source clients are the only state++ * (their in-process caches). The plumbing is the skeleton's, modelled on OpenVibe.Help.++ */++const path = require('path');++const express = require('express');++const helmet = require('helmet');++const rateLimit = require('express-rate-limit');++const contracts = require('openvibe-contracts');++const cache = require('openvibe-shared/cache-policy');++++const configLib = require('./config');++const { createNetworkClient } = require('./data/network');++const { createLiveClient } = require('./data/live');++const { createApi } = require('./http/api');++const { createPageRoutes } = require('./http/pages');++const { createDiscoveryRoutes } = require('./http/discovery');++const { createServiceReadiness } = require('./observability');++const views = require('./views');++const { PUBLIC_DIR, assetVersion } = require('./assets');++++const VERSION = require('../package.json').version;++const PAGE_CACHE = 'public, max-age=120';++++/** opts: config, fetchImpl, log, now, network, live, releaseEnv */++async function createApp(opts = {}) {++ const config = opts.config || configLib.load();++ const log = opts.log || console;++ const fetchImpl = opts.fetchImpl || globalThis.fetch;++ const now = opts.now || (() => Date.now());++++ const network = opts.network || createNetworkClient({ config, fetchImpl, log, now });++ const live = opts.live || createLiveClient({ config, fetchImpl, log, now });++ views.configure({ baseUrl: config.baseUrl });++++ const ctx = { config, log, fetchImpl, now, network, live };++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', config.trustProxy);++++ // Request context (traceparent + X-OpenVibe-Request-Id, req.ov) for every response, including problem+json.++ app.use(contracts.http.middleware());++++ // The OpenVibe Frame (theme-loader, navbar, footer) comes from this site's /shared copy; the inline boot is ours.++ 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'],++ 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' },++ }));++++ // ── Release metadata and Prometheus metrics (the metrics route stays host-local in nginx) ──++ const release = require('openvibe-shared/release').createRelease({++ service: 'report',++ root: path.join(__dirname, '..'),++ env: opts.releaseEnv || process.env,++ });++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'report', release: release.release });++ app.locals.metrics = metrics.registry;++ app.locals.ctx = ctx;++ ctx.release = release;++ ctx.metrics = metrics.registry;++ ctx.metricsStop = metrics.stop;++++ // ── Machine endpoints ───────────────────────────────────++ app.get('/api/health', (_req, res) => res.set('Cache-Control', 'no-store').json({ status: 'ok', service: 'openvibe-report', version: VERSION }));++ release.mount(app, { registry: metrics.registry });++ const readiness = createServiceReadiness({ config, fetchImpl, release: release.release });++ ctx.readiness = readiness;++ app.get('/api/ready', readiness.handler);++++ // ── Static assets: this site's own (content-hashed ?v= → immutable) and the Shared Frame ──++ require('./views').setRelease(release && release.release);++ 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: CORS-open, public for two minutes, 120 requests a minute per address ──++ app.use('/api/v1', (_req, res, next) => {++ res.set('Access-Control-Allow-Origin', '*');++ res.set('Cache-Control', PAGE_CACHE);++ next();++ });++ app.use('/api/v1', rateLimit({++ windowMs: 60_000,++ limit: config.apiRateLimitPerMinute,++ 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({ network, live }));++ app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));++++ // ── Crawl artifacts, then the pages ─────────────────────++ app.use(createDiscoveryRoutes(ctx));++ app.use(createPageRoutes(ctx));++ app.use((_req, res) => {++ res.status(404).set('Cache-Control', PAGE_CACHE).type('html').send(views.notFound());++ });++++ // eslint-disable-next-line no-unused-vars++ app.use((err, req, res, _next) => {++ log.error('[OpenVibe.Report]', err && err.message ? String(err.message).slice(0, 300) : err);++ if (res.headersSent) return;++ if (req.path.startsWith('/api/')) {++ return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });++ }++ return res.status(500).set('Cache-Control', 'no-store').type('text/plain').send('Something went wrong on our side. Try again in a moment.');++ });++++ return { app, ctx };++}++++module.exports = { createApp };nnew file mode 100644@@@ -0,0 +1,30 @@++'use strict';++++/**++ * Content-addressed URLs for this site's own assets: /css/app.css?v=<first 10 hex of SHA-256>. The static++ * middleware treats a matching ?v= as immutable (openvibe-shared/cache-policy); any other URL gets the short++ * public window, because those bytes may change under the same URL.++ */++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++++const PUBLIC_DIR = path.join(__dirname, '..', 'public');++const hashes = new Map();++++function assetVersion(rel) {++ const key = String(rel || '').replace(/^\/+/, '');++ if (hashes.has(key)) return hashes.get(key);++ let version = 'dev';++ try {++ version = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, key))).digest('hex').slice(0, 10);++ } catch {++ /* a missing asset stays at a stable placeholder */++ }++ hashes.set(key, version);++ return version;++}++++const asset = (rel) => `/${String(rel || '').replace(/^\/+/, '')}?v=${assetVersion(rel)}`;++++module.exports = { PUBLIC_DIR, assetVersion, asset };nnew file mode 100644@@@ -0,0 +1,44 @@++'use strict';++++/**++ * OpenVibe.Report configuration. Every value comes from the environment (production: /etc/openvibe/report.env,++ * see .env.example). load(env) is pure so tests can build a config without touching process.env.++ *++ * Report is stateless: it has no database, no sessions and no events. It reads two loopback APIs:++ * OpenVibe.Network's open stats and OpenVibe.Live's Rundown and after-show reports.++ */++require('dotenv').config();++++const trim = (s) => String(s || '').replace(/\/+$/, '');++const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);++++function load(env = process.env) {++ const nodeEnv = env.NODE_ENV || 'development';++ const isProduction = nodeEnv === 'production';++ const port = int(env.PORT, 5080);++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.report' : `http://localhost:${port}`));++++ return {++ service: 'report',++ name: 'OpenVibe.Report',++ port,++ host: env.HOST || '127.0.0.1',++ nodeEnv,++ isProduction,++ baseUrl,++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,++++ // The two sources. Both serve these read APIs on loopback; manifests/services/{network,live}.json in++ // openvibe-contracts name the same defaults.++ networkUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),++ liveUrl: trim(env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),++++ // One caller may make this many API requests a minute (express-rate-limit, keyed by address).++ apiRateLimitPerMinute: Math.max(1, int(env.REPORT_API_RATE_LIMIT_PER_MIN, 120)),++++ // How long a readiness probe may take before the source is reported as not answering.++ readyTimeoutMs: Math.max(200, int(env.REPORT_READY_TIMEOUT_MS, 2500)),++ };++}++++module.exports = { load };nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++++/**++ * A small in-memory cache for the two source clients: a Map with an expiry per entry, 1000 entries at most and++ * the oldest inserted entry dropped first. Read APIs are public and change slowly, so a page view is usually a++ * cache hit; a process restart is the only thing that empties it.++ *++ * const cache = createCache({ max: 1000, now: () => Date.now() });++ * cache.get(key) undefined when absent or expired++ * cache.set(key, value, ttlMs)++ */++function createCache({ max = 1000, now = () => Date.now() } = {}) {++ const entries = new Map();++ const clock = typeof now === 'function' ? now : () => Date.now();++++ function get(key) {++ const hit = entries.get(key);++ if (!hit) return undefined;++ if (hit.expiresAt <= clock()) {++ entries.delete(key);++ return undefined;++ }++ // Do not move it to the end: "oldest out" means insertion order, not least-recently-used.++ return hit.value;++ }++++ function set(key, value, ttlMs) {++ entries.delete(key);++ entries.set(key, { value, expiresAt: clock() + Math.max(0, Number(ttlMs) || 0) });++ while (entries.size > Math.max(1, Number(max) || 1)) {++ const oldest = entries.keys().next().value;++ entries.delete(oldest);++ }++ return value;++ }++++ return {++ get,++ set,++ delete: (key) => entries.delete(key),++ get size() { return entries.size; },++ };++}++++module.exports = { createCache };nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++++/**++ * OpenVibe.Live's read APIs:++ * GET /api/rundown/ the Rundown's home: on air, the lead, streams, trending, voices, counts++ * GET /api/rundown/topics every topic++ * GET /api/rundown/t/:slug one topic and its takes++ * GET /api/rundown/e/:id one stream as an episode++ * GET /api/rundown/s/:user a streamer's episodes, topics and lines++ * GET /api/recap/:id an after-show report++ * GET /api/recap/channel/:user a channel's latest reports++ *++ * Answers are held in this process (2 to 10 minutes, as each source changes); a failure answers null after++ * one short log line. Path parameters are checked here, before a request is made: a slug that cannot exist is++ * a 404 without asking Live.++ */++const { createCache } = require('./cache');++const { trim, requestJson, isSlug, streamIdOf, isUsername, TIMEOUT_MS } = require('./source');++++const TTL = {++ rundown: 2 * 60_000,++ topics: 5 * 60_000,++ topic: 5 * 60_000,++ episode: 5 * 60_000,++ speaker: 5 * 60_000,++ recap: 10 * 60_000,++ channelRecaps: 5 * 60_000,++};++++/**++ * opts: config ({ liveUrl }), fetchImpl, log, now, timeoutMs, cacheMax++ */++function createLiveClient({ config = {}, fetchImpl = globalThis.fetch, log = console, now = () => Date.now(), timeoutMs = TIMEOUT_MS, cacheMax = 1000 } = {}) {++ const baseUrl = trim(config.liveUrl || config.liveInternalUrl || 'http://127.0.0.1:3000');++ const cache = createCache({ max: cacheMax, now });++++ async function cached(key, path, ttl, label) {++ const hit = cache.get(key);++ if (hit !== undefined) return hit;++ const data = await requestJson(`${baseUrl}${path}`, { fetchImpl, log, timeoutMs, label });++ if (data === null) return null;++ return cache.set(key, data, ttl);++ }++++ function rundown() {++ return cached('rundown:home', '/api/rundown/', TTL.rundown, 'live rundown');++ }++++ function topics() {++ return cached('rundown:topics', '/api/rundown/topics', TTL.topics, 'live topics');++ }++++ function topic(slug) {++ if (!isSlug(slug)) return null;++ return cached(`rundown:topic:${slug}`, `/api/rundown/t/${encodeURIComponent(slug)}`, TTL.topic, 'live topic');++ }++++ /** Live's episode route is named by the stream id (a positive integer). */++ function episode(streamId) {++ const id = streamIdOf(streamId);++ if (!id) return null;++ return cached(`rundown:episode:${id}`, `/api/rundown/e/${id}`, TTL.episode, 'live episode');++ }++++ function speaker(username) {++ if (!isUsername(username)) return null;++ const key = username.toLowerCase();++ return cached(`rundown:speaker:${key}`, `/api/rundown/s/${encodeURIComponent(username)}`, TTL.speaker, 'live speaker');++ }++++ function recap(streamId) {++ const id = streamIdOf(streamId);++ if (!id) return null;++ return cached(`recap:${id}`, `/api/recap/${id}`, TTL.recap, 'live recap');++ }++++ function channelRecaps(username) {++ if (!isUsername(username)) return null;++ const key = username.toLowerCase();++ return cached(`recaps:channel:${key}`, `/api/recap/channel/${encodeURIComponent(username)}`, TTL.channelRecaps, 'live channel recaps');++ }++++ return { rundown, topics, topic, episode, speaker, recap, channelRecaps, cache, baseUrl, ttlMs: TTL };++}++++module.exports = { createLiveClient, TTL };nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++++/**++ * OpenVibe.Network's open stats: GET /api/v1/stats?days=7|30|90.++ *++ * The answer is public counts only. It is held for five minutes in this process and a failure answers null++ * (after one short log line), never an exception: a page renders "not available", never a made-up zero.++ */++const { createCache } = require('./cache');++const { trim, requestJson, normaliseDays, TIMEOUT_MS } = require('./source');++++const STATS_TTL_MS = 5 * 60_000;++++/**++ * opts: config ({ networkUrl }), fetchImpl, log, now, timeoutMs, cacheMax++ */++function createNetworkClient({ config = {}, fetchImpl = globalThis.fetch, log = console, now = () => Date.now(), timeoutMs = TIMEOUT_MS, cacheMax = 1000 } = {}) {++ const baseUrl = trim(config.networkUrl || config.networkInternalUrl || 'http://127.0.0.1:4000');++ const cache = createCache({ max: cacheMax, now });++++ /** The numbers for the last 7, 30 or 90 days; 30 for anything else. */++ async function stats(days) {++ const window = normaliseDays(days);++ const hit = cache.get(window);++ if (hit !== undefined) return hit;++ const data = await requestJson(`${baseUrl}/api/v1/stats?days=${window}`, {++ fetchImpl, log, timeoutMs, label: `network stats (${window}d)`,++ });++ if (data === null) return null;++ return cache.set(window, data, STATS_TTL_MS);++ }++++ return { stats, cache, baseUrl, ttlMs: STATS_TTL_MS };++}++++module.exports = { createNetworkClient, STATS_TTL_MS };nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++++/**++ * Shared pieces for the two read-only source clients (server/data/network.js and server/data/live.js):++ * one short failure log line, the 8 s timeout, and the parameter rules. A source that cannot answer is++ * never an exception the page has to catch — it is null.++ */++++const TIMEOUT_MS = 8_000;++const SLUG_RE = /^[a-z0-9-]{1,80}$/;++const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;++++const trim = (s) => String(s || '').replace(/\/+$/, '');++++function reasonOf(err) {++ if (!err) return 'no response';++ if (err.name === 'TimeoutError' || err.name === 'AbortError') return 'timeout';++ return String(err.message || err).replace(/\s+/g, ' ').slice(0, 160);++}++++/** One short line, never the request body, never a stack. */++function warn(log, message) {++ const line = `[OpenVibe.Report] ${String(message).replace(/\s+/g, ' ').slice(0, 240)}`;++ try {++ const fn = (log && (log.warn || log.error)) || console.warn;++ fn.call(log || console, line);++ } catch {++ /* logging must never fail the page */++ }++}++++/**++ * GET a JSON document with an 8 s timeout. Returns the parsed body, or null after one log line.++ * A 404 is the source answering "no such thing", so it is null without a log line.++ */++async function requestJson(url, { fetchImpl = globalThis.fetch, log = console, label = 'source', timeoutMs = TIMEOUT_MS } = {}) {++ let res;++ try {++ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'++ ? AbortSignal.timeout(timeoutMs)++ : undefined;++ res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal });++ } catch (err) {++ warn(log, `${label} failed: ${reasonOf(err)}`);++ return null;++ }++++ if (!res || !res.ok) {++ if (!res || res.status !== 404) warn(log, `${label} failed: HTTP ${res ? res.status : 'no response'}`);++ return null;++ }++++ try {++ return await res.json();++ } catch (err) {++ warn(log, `${label} failed: ${reasonOf(err)}`);++ return null;++ }++}++++/** ?days=7|30|90; anything else is 30, exactly as OpenVibe.Network's own route reads it. */++function normaliseDays(value) {++ const raw = Array.isArray(value) ? value[0] : value;++ const days = parseInt(raw, 10);++ return [7, 30, 90].includes(days) ? days : 30;++}++++const isSlug = (value) => typeof value === 'string' && SLUG_RE.test(value);++++/** A positive integer stream id. Returns the number, or null. */++function streamIdOf(value) {++ const raw = String(value == null ? '' : value);++ if (!/^\d{1,18}$/.test(raw)) return null;++ const id = Number(raw);++ return Number.isSafeInteger(id) && id > 0 ? id : null;++}++++const isStreamId = (value) => streamIdOf(value) !== null;++const isUsername = (value) => typeof value === 'string' && USERNAME_RE.test(value);++++module.exports = { TIMEOUT_MS, trim, warn, requestJson, normaliseDays, isSlug, streamIdOf, isStreamId, isUsername };nnew file mode 100644@@@ -0,0 +1,89 @@++'use strict';++++/**++ * /api/v1 — the read APIs of OpenVibe.Network and OpenVibe.Live, proxied as the source answers them.++ *++ * GET /api/v1/stats?days=7|30|90++ * GET /api/v1/rundown++ * GET /api/v1/rundown/topics++ * GET /api/v1/rundown/t/:slug++ * GET /api/v1/after-show/:streamId++ * GET /api/v1/c/:username++ *++ * A bad parameter is checked here, before the source is asked, and is a 404 problem+json, exactly like a source++ * that answered nothing. Every /api/v1 answer is CORS-open and public for two minutes; the app mounts the 120/min++ * per-address limit in front of this router.++ */++const express = require('express');++const contracts = require('openvibe-contracts');++const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');++++const CACHE = 'public, max-age=120';++++function createApi({ network, live }) {++ const r = express.Router();++++ r.use((req, res, next) => {++ res.set('Access-Control-Allow-Origin', '*');++ res.set('Cache-Control', CACHE);++ if (req.method !== 'GET' && req.method !== 'HEAD') {++ res.set('Allow', 'GET, HEAD');++ return problem(req, res, 405, 'method.not_allowed', 'This API is read-only; use GET.');++ }++ return next();++ });++++ function problem(req, res, status, code, detail) {++ return contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });++ }++++ function notFound(req, res, code = 'report.not_found', detail = 'No such answer from the source.') {++ return problem(req, res, 404, code, detail);++ }++++ r.get('/stats', async (req, res) => {++ const stats = await network.stats(normaliseDays(req.query.days));++ if (!stats) return notFound(req, res, 'report.stats_not_found', 'OpenVibe.Network did not answer with the open numbers.');++ return res.json(stats);++ });++++ r.get('/rundown', async (req, res) => {++ const rundown = await live.rundown();++ if (!rundown) return notFound(req, res, 'report.rundown_not_found', 'OpenVibe.Live did not answer with The Rundown.');++ return res.json(rundown);++ });++++ r.get('/rundown/topics', async (req, res) => {++ const topics = await live.topics();++ if (!topics) return notFound(req, res, 'report.topics_not_found', 'OpenVibe.Live did not answer with the topics.');++ return res.json(topics);++ });++++ r.get('/rundown/t/:slug', async (req, res) => {++ const { slug } = req.params;++ if (!isSlug(slug)) return notFound(req, res, 'report.topic_not_found', 'No such topic.');++ const topic = await live.topic(slug);++ if (!topic) return notFound(req, res, 'report.topic_not_found', 'No such topic.');++ return res.json(topic);++ });++++ r.get('/after-show/:streamId', async (req, res) => {++ if (!isStreamId(req.params.streamId)) return notFound(req, res, 'report.recap_not_found', 'No such after-show report.');++ const recap = await live.recap(streamIdOf(req.params.streamId));++ if (!recap) return notFound(req, res, 'report.recap_not_found', 'No such after-show report.');++ return res.json(recap);++ });++++ r.get('/c/:username', async (req, res) => {++ const { username } = req.params;++ if (!isUsername(username)) return notFound(req, res, 'report.channel_not_found', 'No such channel.');++ const recaps = await live.channelRecaps(username);++ if (!recaps) return notFound(req, res, 'report.channel_not_found', 'No such channel.');++ return res.json(recaps);++ });++++ r.use((req, res) => notFound(req, res, 'route.not_found', 'No such API route.'));++ return r;++}++++module.exports = { createApi };nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++++/**++ * Crawl artifacts for openvibe.report, built with openvibe-shared/seo:++ *++ * GET /robots.txt the public pages are crawlable; the API is not++ * GET /sitemap.xml /, /stats, /rundown and every /rundown/t/<slug> from the Rundown's topics++ * GET /llms.txt a markdown map of the site and its machine-readable answers++ *++ * The sitemap reads topics through the same cached client as the pages, so a crawler is no heavier than a reader++ * and a source that does not answer leaves the three static pages in place.++ */++const fs = require('fs');++const path = require('path');++const seo = require('openvibe-shared/seo');++const express = require('express');++++const SITE_NAME = 'OpenVibe.Report';++const DESCRIPTION = 'OpenVibe.Report is the Report room of the OpenVibe network: the open numbers every OpenVibe site publishes, The Rundown of what streamers talked about, and each stream\'s after-show report.';++const CACHE = 'public, max-age=3600';++++function dayOf(value) {++ const m = String(value == null ? '' : value).match(/^(\d{4}-\d{2}-\d{2})/);++ return m ? m[1] : null;++}++++function siteUpdated() {++ try {++ return dayOf(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated);++ } catch {++ return null;++ }++}++++function createDiscoveryRoutes({ config, live }) {++ const r = express.Router();++ const site = String(config.baseUrl || 'https://openvibe.report').replace(/\/+$/, '');++ const abs = (p) => `${site}${p}`;++++ r.get('/robots.txt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', CACHE).send(++ '# openvibe.report: the public pages are for search and AI crawlers; the JSON API is not.\n'++ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/api/'] }),++ );++ });++++ r.get('/sitemap.xml', async (_req, res) => {++ const data = await live.topics();++ const topics = (data && Array.isArray(data.topics)) ? data.topics : [];++ const lastmod = siteUpdated();++ const pages = [++ { loc: abs('/'), changefreq: 'daily', priority: 1.0 },++ { loc: abs('/stats'), changefreq: 'daily', priority: 0.9 },++ { loc: abs('/rundown'), changefreq: 'daily', priority: 0.9 },++ ...topics.filter((t) => t && typeof t.slug === 'string').map((t) => ({++ loc: abs(`/rundown/t/${encodeURIComponent(t.slug)}`),++ changefreq: 'weekly',++ priority: 0.7,++ })),++ ].map((p) => ({ ...p, ...(lastmod ? { lastmod } : {}) }));++ res.type('application/xml').set('Cache-Control', CACHE).send(seo.sitemapXml(pages));++ });++++ r.get('/llms.txt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', CACHE).send(seo.llmsTxt({++ name: SITE_NAME,++ summary: 'Every number on OpenVibe, honest: the network\'s open stats, The Rundown of what streamers talked about, and every stream\'s after-show report.',++ details: 'The numbers and the write-ups are read from OpenVibe.Network and OpenVibe.Live, cached in this process for a few minutes, and shown as the sources answer them. Nothing here is about a person.',++ sections: [++ { title: 'Start here', links: [++ { title: 'OpenVibe.Report', url: abs('/'), note: 'the Report room' },++ { title: 'Open numbers', url: abs('/stats'), note: 'page views, accounts and traffic for every OpenVibe site' },++ { title: 'The Rundown', url: abs('/rundown'), note: 'what streamers talked about, topic by topic' },++ ] },++ { title: 'Machine-readable', links: [++ { title: 'Open stats (JSON)', url: abs('/api/v1/stats?days=7'), note: '?days=7, 30 or 90' },++ { title: 'The Rundown (JSON)', url: abs('/api/v1/rundown') },++ { title: 'Topics (JSON)', url: abs('/api/v1/rundown/topics') },++ { title: 'Sitemap', url: abs('/sitemap.xml') },++ { title: 'Release metadata (JSON)', url: abs('/release.json') },++ ] },++ { title: 'Elsewhere', links: [++ { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'the network and its open stats' },++ { title: 'OpenVibe.Live', url: 'https://openvibe.live', note: 'the streams, The Rundown and the after-show reports' },++ ] },++ ],++ }));++ });++++ return r;++}++++module.exports = { createDiscoveryRoutes, DESCRIPTION, SITE_NAME };nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++++/**++ * The pages, server-rendered from a plain model (server/views/index.js):++ *++ * GET / stats 7 days + the Rundown + topics++ * GET /stats?days= 7 | 30 | 90 (anything else is 30)++ * GET /rundown the Rundown + topics++ * GET /rundown/t/:slug one topic++ * GET /rundown/e/:streamId one stream as an episode++ * GET /rundown/s/:username one streamer++ * GET /after-show/:streamId one after-show report++ * GET /c/:username one channel's after-show reports++ *++ * A bad path parameter is a 404 without a source request; so is a source that answers nothing. Pages are public++ * for two minutes.++ */++const express = require('express');++const views = require('../views');++const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');++++const CACHE = 'public, max-age=120';++++function createPageRoutes({ network, live }) {++ const r = express.Router();++++ function send(res, html, status = 200) {++ res.status(status).set('Cache-Control', CACHE).type('html').send(html);++ }++ function notFound(res) {++ return send(res, views.notFound(), 404);++ }++++ r.get('/', async (_req, res) => {++ const [stats, rundown, topics] = await Promise.all([++ network.stats(7),++ live.rundown(),++ live.topics(),++ ]);++ // Each part of the home stands alone: a source that does not answer leaves its part out, not the page.++ return send(res, views.home({ stats, rundown, topics }));++ });++++ r.get('/developers', (_req, res) => send(res, views.developers()));++++ r.get('/stats', async (req, res) => {++ const days = normaliseDays(req.query.days);++ const stats = await network.stats(days);++ if (!stats) return notFound(res);++ return send(res, views.stats({ stats, days }));++ });++++ r.get('/rundown', async (_req, res) => {++ const [rundown, topics] = await Promise.all([live.rundown(), live.topics()]);++ if (!rundown || !topics) return notFound(res);++ return send(res, views.rundown({ rundown, topics }));++ });++++ r.get('/rundown/t/:slug', async (req, res) => {++ const { slug } = req.params;++ if (!isSlug(slug)) return notFound(res);++ const topic = await live.topic(slug);++ if (!topic) return notFound(res);++ return send(res, views.topic({ topic }));++ });++++ r.get('/rundown/e/:streamId', async (req, res) => {++ if (!isStreamId(req.params.streamId)) return notFound(res);++ const episode = await live.episode(streamIdOf(req.params.streamId));++ if (!episode) return notFound(res);++ return send(res, views.episode({ episode }));++ });++++ r.get('/rundown/s/:username', async (req, res) => {++ const { username } = req.params;++ if (!isUsername(username)) return notFound(res);++ const speaker = await live.speaker(username);++ if (!speaker) return notFound(res);++ return send(res, views.speaker({ speaker, username }));++ });++++ r.get('/after-show/:streamId', async (req, res) => {++ if (!isStreamId(req.params.streamId)) return notFound(res);++ const recap = await live.recap(streamIdOf(req.params.streamId));++ if (!recap) return notFound(res);++ return send(res, views.recap({ recap }));++ });++++ r.get('/c/:username', async (req, res) => {++ const { username } = req.params;++ if (!isUsername(username)) return notFound(res);++ const recaps = await live.channelRecaps(username);++ if (!recaps) return notFound(res);++ return send(res, views.channel({ username, recaps }));++ });++++ return r;++}++++module.exports = { createPageRoutes };nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++++/**++ * OpenVibe.Report — process entry. `node server/index.js`++ * Listens on PORT (5080) behind nginx (deploy/).++ */++const { createApp } = require('./app');++const { gracefulStop } = require('openvibe-sdk/service');++++/**++ * The process stop (openvibe-sdk/service): the HTTP drain runs, then anything the product added closes. Report++ * has no database, no key refresher and no subscriptions, so there is nothing else to close. Exported so a test can++ * inject `exit` and `signals: false`.++ */++function createLifecycle({ server, exit, signals } = {}) {++ return gracefulStop({++ name: 'OpenVibe.Report',++ server,++ deadlineExitCode: 0,++ exit,++ signals,++ deadlineMs: 10_000,++ });++}++++async function start() {++ const { app, ctx } = await createApp();++ const { config } = ctx;++++ const server = app.listen(config.port, config.host, () => {++ console.log(`[OpenVibe.Report] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (network ${config.networkUrl}, live ${config.liveUrl})`);++ });++ server.keepAliveTimeout = 65_000;++ createLifecycle({ server, ctx });++ return { server, ctx };++}++++if (require.main === module) {++ start().catch((err) => {++ console.error('[OpenVibe.Report] failed to start:', err);++ process.exit(1);++ });++}++++module.exports = { start, createLifecycle };nnew file mode 100644@@@ -0,0 +1,46 @@++'use strict';++++/**++ * Truthful readiness for GET /api/ready (openvibe-shared/ready, Track O).++ *++ * Report has no database, no sessions and no queues: its only dependencies are the two loopback read APIs.++ * Both checks are optional, so a source that does not answer makes the service degraded, never not_ready:++ * the process still serves the pages it can and the 503 would stop the deploy of a stateless front.++ *++ * network optional OpenVibe.Network answers /api/health++ * live optional OpenVibe.Live answers /api/health++ */++const { createReadiness } = require('openvibe-shared/ready');++++function createServiceReadiness({ config, fetchImpl = globalThis.fetch, release = null }) {++ const healthOf = (name, origin) => ({++ name,++ required: false,++ description: `${name} answers its health endpoint`,++ async check() {++ const url = `${String(origin || '').replace(/\/+$/, '')}/api/health`;++ try {++ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'++ ? AbortSignal.timeout(config.readyTimeoutMs || 2500)++ : undefined;++ const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal });++ if (res && res.ok) return true;++ return `${name} answered HTTP ${res ? res.status : 'nothing'}`;++ } catch (err) {++ return `${name} did not answer: ${(err && err.message) || 'fetch failed'}`;++ }++ },++ });++++ return createReadiness({++ service: 'report',++ release,++ timeoutMs: config.readyTimeoutMs || 2500,++ checks: [++ healthOf('network', config.networkUrl),++ healthOf('live', config.liveUrl),++ ],++ });++}++++module.exports = { createServiceReadiness };nnew file mode 100644@@@ -0,0 +1,317 @@++'use strict';++/**++ * The Report room's pages (plan 2026-10-28 §5.4). Server-rendered and whole without JavaScript; the Frame (navbar,++ * theme, footer) comes from openvibe-shared/shell, the front door from sc.room() and the accent from rooms.js.++ * server/http/pages.js builds each model from Network's open stats and Live's Rundown and after-show reports. Every++ * number shown is one a source gave, with its window or date; a missing one is left out, never shown as 0.++ *++ * AI-written text (headlines, summaries, takes) is labelled. The after-show report never prints the raw speech sample++ * or quotes of the transcript: it shows the AI's titles and topics and links to the moment in the recording.++ */++const shell = require('openvibe-shared/shell');++const ovServe = require('openvibe-shared/serve');++const appIcon = require('openvibe-shared/app-icon');++const kit = require('openvibe-shared/kit');++const sc = require('openvibe-shared/showcase');++const rooms = require('openvibe-shared/rooms');++const { asset } = require('../assets');++++const NAME = 'OpenVibe.Report';++const NETWORK = 'https://openvibe.network';++const ROOM = rooms.get('report');++const esc = kit.esc;++let RELEASE = 'dev';++let BASE = 'https://openvibe.report';++function setRelease(id) { if (id) RELEASE = String(id); }++function configure({ baseUrl } = {}) { if (baseUrl) BASE = String(baseUrl).replace(/\/+$/, ''); }++const canon = (path) => `${BASE}${path}`;++++// ── Formatting: numbers as people read them; dates in UTC (Live stores "YYYY-MM-DD hh:mm:ss" in UTC) ──++const n = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));++const fmt = (v) => { const x = n(v); if (x == null) return '—'; return Math.abs(x) >= 1e6 ? `${(x / 1e6).toFixed(1).replace(/\.0$/, '')}M` : Math.abs(x) >= 1e4 ? `${Math.round(x / 1e3)}k` : x.toLocaleString('en-US'); };++const exact = (v) => { const x = n(v); return x == null ? '—' : x.toLocaleString('en-US'); };++const ts = (s) => { const str = String(s || ''); const t = Date.parse(/[TZ]/.test(str) || !str.includes(' ') ? str : `${str.replace(' ', 'T')}Z`); return Number.isFinite(t) ? t : null; };++const day = (s) => { const t = ts(s); return t == null ? '' : new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }); };++const dayTime = (s) => { const t = ts(s); return t == null ? '' : `${new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })}, ${new Date(t).toISOString().slice(11, 16)} UTC`; };++const mins = (sec) => { const s = n(sec); if (s == null) return ''; const m = Math.round(s / 60); return m >= 60 ? `${Math.floor(m / 60)} h ${m % 60} min` : `${m} min`; };++const pct = (v) => { const x = n(v); if (x == null) return ''; return `<span class="rp-pill ${x > 0 ? 'is-up' : x < 0 ? 'is-down' : 'is-flat'}">${x > 0 ? '▲' : x < 0 ? '▼' : '•'} ${Math.abs(x)}%</span>`; };++const who = (u) => (u ? u.display_name || u.username || '' : '');++const AI = '<span class="rp-ai" title="Written by AI from the stream\'s own speech, chat and numbers">Written by AI</span>';++++const NAV = [++ { label: 'Numbers', href: '/stats', icon: 'fa-chart-line' },++ { label: 'The Rundown', href: '/rundown', icon: 'fa-newspaper' },++ { label: 'API', href: '/developers', icon: 'fa-code', more: true },++];++++function layout({ title, description, canonical = null, path = null, robots = 'index, follow', body, active = '/', jsonLd = [] }) {++ canonical = canonical || (path ? canon(path) : undefined);++ const navbar = {++ service: 'report',++ apiBase: NETWORK,++ links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && active.startsWith(l.href)) })),++ appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Numbers', href: '/stats', icon: 'fa-chart-line' }, { label: 'Rundown', href: '/rundown', icon: 'fa-newspaper' }],++ history: { type: 'page', title: title || NAME },++ };++ const footer = { service: 'report', variant: 'full', mount: '#ov-footer', brandName: NAME };++ return shell.page({++ name: NAME,++ lang: 'en',++ title: title || `${NAME}: ${ROOM.line}`,++ titleSuffix: title ? ` · ${NAME}` : '',++ siteName: NAME,++ description: description || 'The network\'s numbers open to everyone, what streamers talked about in The Rundown, and every stream\'s after-show report.',++ canonical,++ robots,++ jsonLd,++ home: '/',++ navLinks: NAV,++ navbar,++ footer,++ head: `${appIcon.headTags({ site: 'report' })}++<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="${esc(asset('css/app.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="report@${esc(RELEASE)}">`,++ body: `<a class="skip" href="#main">Skip to content</a>++<div id="navbar-mount"></div>++<main id="main" class="rp ov-room" style="${rooms.style('report')}"><div class="rp-wrap">${body}</div></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) { /* optional */ } });</script>`,++ });++}++++// ── Charts: server-drawn SVG, crisp at any size, each with its numbers for screen readers ──++function bars(points, label, { h = 140 } = {}) {++ const pts = (points || []).filter((p) => p && n(p.n) != null);++ if (pts.length < 2) return '';++ const max = Math.max(1, ...pts.map((p) => Number(p.n)));++ const w = 100 / pts.length;++ const cols = pts.map((p, i) => { const v = Number(p.n); const bh = Math.max(1.5, (v / max) * (h - 18)); return `<rect x="${(i * w + w * 0.14).toFixed(2)}%" y="${(h - bh).toFixed(1)}" width="${(w * 0.72).toFixed(2)}%" height="${bh.toFixed(1)}" rx="3"><title>${esc(day(p.day))}: ${exact(v)}</title></rect>`; }).join('');++ const first = pts[0], last = pts[pts.length - 1];++ return `<figure class="rp-bars"><svg viewBox="0 0 100 ${h}" preserveAspectRatio="none" role="img" aria-label="${esc(`${label}: ${pts.map((p) => `${day(p.day)} ${exact(p.n)}`).join(', ')}`)}">${cols}</svg>`++ + `<figcaption><span>${esc(day(first.day))}</span><span>${esc(day(last.day))}</span></figcaption></figure>`;++}++function spark(points) {++ const vals = (points || []).map((p) => n(p && p.n)).filter((v) => v != null);++ if (vals.length < 2) return '';++ const max = Math.max(1, ...vals), W = 240, H = 56;++ const xy = vals.map((v, i) => [(i / (vals.length - 1)) * W, H - 4 - (v / max) * (H - 10)]);++ const line = xy.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`).join(' ');++ return `<svg class="rp-spark" viewBox="0 0 ${W} ${H}" aria-hidden="true"><path class="rp-spark-area" d="${line} L${W} ${H} L0 ${H}Z"/><path class="rp-spark-line" d="${line}"/></svg>`;++}++/** A stream's viewers minute by minute (Live's curve: [time, viewers, chat messages]). */++function curve(points) {++ const pts = (points || []).filter((p) => Array.isArray(p) && ts(p[0]) != null);++ if (pts.length < 2) return '';++ const W = 600, H = 150, t0 = ts(pts[0][0]), t1 = ts(pts[pts.length - 1][0]);++ const maxV = Math.max(1, ...pts.map((p) => Number(p[1]) || 0)), maxC = Math.max(1, ...pts.map((p) => Number(p[2]) || 0));++ const X = (t) => ((t - t0) / Math.max(1, t1 - t0)) * W;++ const view = pts.map((p, i) => `${i ? 'L' : 'M'}${X(ts(p[0])).toFixed(1)} ${(H - 8 - ((Number(p[1]) || 0) / maxV) * (H - 24)).toFixed(1)}`).join(' ');++ const chat = pts.map((p) => { const c = Number(p[2]) || 0; if (!c) return ''; const bh = (c / maxC) * 36; return `<rect x="${(X(ts(p[0])) - 1.5).toFixed(1)}" y="${(H - bh).toFixed(1)}" width="3" height="${bh.toFixed(1)}" rx="1"/>`; }).join('');++ return `<figure class="rp-curve"><svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="${esc(`Viewers over the stream, peak ${exact(maxV)}; chat messages as bars`)}"><g class="rp-curve-chat">${chat}</g><path class="rp-curve-area" d="${view} L${W} ${H} L0 ${H}Z"/><path class="rp-curve-line" d="${view}"/></svg>`++ + `<figcaption><span>${esc(dayTime(pts[0][0]))}</span><span class="rp-legend"><i class="is-v"></i>viewers <i class="is-c"></i>chat</span><span>${esc(new Date(t1).toISOString().slice(11, 16))} UTC</span></figcaption></figure>`;++}++++const tile = (label, value, sub = '', extra = '') => (value == null || value === '' ? '' : `<div class="rp-tile"><span class="rp-tile-l">${esc(label)}</span><b class="rp-tile-v">${value}</b>${sub ? `<span class="rp-tile-s">${sub}</span>` : ''}${extra}</div>`);++const KIND = { games: 'Games', chat: 'Chat', irl: 'IRL', news: 'News', tech: 'Tech', music: 'Music', art: 'Art' };++++/** A Rundown take or segment as a row: the AI's title, what was said (AI), the streamer and when. */++function takeRow(t) {++ if (!t) return '';++ const sid = t.stream_id || (t.stream && t.stream.id);++ const by = who(t.user || (t.stream && t.stream.user));++ return `<li class="rp-take"><h3>${sid ? `<a href="/rundown/e/${encodeURIComponent(sid)}">${esc(t.title)}</a>` : esc(t.title)}</h3>${t.what ? `<p>${esc(t.what)}</p>` : ''}`++ + `<p class="rp-meta">${by ? `<a href="/rundown/s/${encodeURIComponent((t.user || (t.stream && t.stream.user) || {}).username || '')}">${esc(by)}</a>` : ''}${t.at || t.started_at ? `<time>${esc(dayTime(t.at || t.started_at))}</time>` : ''}</p></li>`;++}++function topicCard(t) {++ if (!t || !t.slug) return '';++ return `<li><a class="rp-topic" href="/rundown/t/${encodeURIComponent(t.slug)}"><span class="rp-kind">${esc(KIND[t.kind] || t.kind || 'Topic')}</span><b>${esc(t.title)}</b>${t.headline ? `<span class="rp-topic-h">${esc(t.headline)}</span>` : ''}`++ + `<span class="rp-meta">${[n(t.takes) ? `${exact(t.takes)} takes` : '', n(t.streamers) ? `${exact(t.streamers)} streamer${Number(t.streamers) === 1 ? '' : 's'}` : '', t.last_seen_at ? day(t.last_seen_at) : ''].filter(Boolean).map(esc).join(' · ')}</span></a></li>`;++}++function streamCard(s) {++ if (!s || !s.stream_id) return '';++ const ep = s.episode && s.episode.title ? s.episode : null;++ return kit.card({ href: `/after-show/${s.stream_id}`, title: ep ? ep.title : s.title || 'Stream', peek: ep ? (ep.logline || s.title || '') : '', type: 'stream', live: !!s.live, corner: n(s.minutes) ? `${s.minutes} min` : '', who: who(s.user), avatar: s.user && s.user.avatar_url, when: s.started_at ? ts(s.started_at) : null, image: s.thumbnail_url || '', count: n(s.takes_total) ? [s.takes_total, 'takes'] : null });++}++++// ── Pages ──++function home({ stats, rundown, topics } = {}) {++ const T = (stats && stats.totals) || {};++ const L = (stats && stats.live) || {};++ const R = rundown || {};++ const streams = (R.streams || []).slice(0, 10).map(streamCard).filter(Boolean);++ const trending = ((topics && topics.topics) || R.trending || []).slice(0, 6);++ const lead = R.lead;++ const leadHTML = lead ? `<section class="rp-lead" aria-labelledby="rp-lead-h"><p class="rp-kicker">The Rundown · lead</p><h2 id="rp-lead-h">${lead.stream_id || (lead.stream && lead.stream.id) ? `<a href="/rundown/e/${encodeURIComponent(lead.stream_id || lead.stream.id)}">${esc(lead.title)}</a>` : esc(lead.title)}</h2>`++ + `${lead.what ? `<p class="rp-lead-what">${esc(lead.what)}</p>` : ''}${lead.take ? `<p class="rp-lead-take">${esc(lead.take)}</p>` : ''}<p class="rp-meta">${AI}${who(lead.user) ? ` · ${esc(who(lead.user))}` : ''}</p></section>` : '';++ const topicsHTML = trending.length ? `<section aria-labelledby="rp-tr-h"><div class="rp-head-row"><h2 id="rp-tr-h">What streamers talked about</h2><a class="rp-more" href="/rundown">The Rundown${kit.icon('chevron')}</a></div><ul class="rp-topics">${trending.map(topicCard).join('')}</ul></section>` : '';++ const pv = stats && stats.pageviews_daily;++ const numbersScene = pv && pv.length > 1 ? {++ title: 'Every number says what it counts',++ text: `${stats.note || 'Counts only, never about a person.'} The same numbers are open JSON for anyone to reuse.`,++ html: `<div class="rp-scene-num"><span class="rp-tile-l">Page views, last ${esc(stats.window_days || 7)} days</span><b>${exact(T.pageviews)}</b>${pct(T.change_pct)}${spark(pv)}</div>`,++ action: { href: '/stats', label: 'All the numbers' },++ } : null;++ const body = sc.room({++ room: 'report',++ hero: {++ title: ROOM.line,++ lede: 'The network\'s numbers, open to everyone. What streamers talked about, written up. Every stream\'s after-show report. Each number with what it counts and when.',++ start: { href: '/stats', label: 'See the numbers' },++ chips: [{ label: 'Last 7 days', href: '/stats?days=7' }, { label: 'Last 30 days', href: '/stats?days=30' }, { label: 'The Rundown', href: '/rundown' }],++ },++ proof: [++ { value: n(T.pageviews), label: 'page views this week', asOf: stats && stats.generated_at ? day(stats.generated_at) : null },++ { value: n(T.accounts), label: 'accounts' },++ { value: n(L.streams), label: 'streams so far' },++ { value: n(R.counts && R.counts.takes), label: 'takes written up' },++ ].filter((p) => p.value),++ rails: streams.length ? [{ id: 'after-show', title: 'After-show reports', more: { href: '/rundown', label: 'The Rundown' }, items: streams }] : [],++ html: leadHTML + topicsHTML,++ scenes: [numbersScene].filter(Boolean),++ explore: { title: 'More on Report', items: [++ { title: 'Every site\'s numbers', text: 'Page views, people, streams and how fast each service answers', href: '/stats', icon: 'ov:gauge' },++ { title: 'The Rundown', text: 'What streamers talked about, topic by topic', href: '/rundown', icon: 'ov:news' },++ { title: 'Status', text: 'Whether every OpenVibe service is up right now', href: 'https://openvibe.network/status', icon: 'ov:ping' },++ ] },++ dev: { title: 'For developers', lede: 'Every number here is open JSON, readable from any site.', samples: [++ { label: 'The network, last 30 days', lang: 'bash', code: 'curl "https://openvibe.report/api/v1/stats?days=30"' },++ { label: 'The Rundown', lang: 'bash', code: 'curl https://openvibe.report/api/v1/rundown/topics' },++ ], links: [{ label: 'The API', href: '/developers' }, { label: 'llms.txt', href: '/llms.txt' }] },++ faq: { items: [++ ['Where do these numbers come from?', 'From the services themselves: page views from the shared navbar\'s anonymous counter and OpenVibe.Live\'s own analytics, traffic from the servers\' metrics, streams and chat from Live. Each page says what a number counts and when it was read.'],++ ['Is anything here about me?', 'No. Every number is a count. The Rundown and the after-show reports are about public streams, and a streamer can opt out of The Rundown.'],++ ['Who writes the after-show reports?', 'An AI, from the stream\'s own numbers, chat and speech, and it says so on every report. It never describes how anyone looks.'],++ ] },++ });++ return layout({ title: null, body, active: '/', path: '/' });++}++++function stats({ stats: d, days = 30 } = {}) {++ const T = d.totals || {}, L = d.live, C = d.coins, A = d.accounts;++ const tabs = [7, 30, 90].map((x) => `<a href="/stats?days=${x}"${Number(days) === x ? ' aria-current="page"' : ''}>${x} days</a>`).join('');++ const sites = (d.sites || []).filter((s) => s && s.name).map((s) => `<div class="rp-tr" role="row"><span role="cell" class="rp-site"><a href="${esc(s.url || '#')}">${esc(s.name || s.short)}</a><small>${esc(s.host || '')}</small></span>`++ + `<span role="cell" data-l="Page views">${s.pageviews == null ? '—' : exact(s.pageviews)} ${pct(s.change_pct)}</span>`++ + `<span role="cell" data-l="Visitors">${s.unique_visitors == null ? '—' : exact(s.unique_visitors)}</span>`++ + `<span role="cell" data-l="Requests, 24 h">${s.traffic_24h && s.traffic_24h.requests != null ? fmt(s.traffic_24h.requests) : '—'}</span>`++ + `<span role="cell" data-l="95% answered in">${s.traffic_24h && s.traffic_24h.p95_ms != null ? `${exact(s.traffic_24h.p95_ms)} ms` : '—'}</span></div>`).join('');++ const body = `<header class="rp-page-h"><p class="rp-kicker">Open numbers</p><h1>Every OpenVibe site's numbers</h1>++<p class="rp-lede">Public for everyone, counts only, never about a person.${d.generated_at ? ` Read ${esc(dayTime(d.generated_at))}.` : ''}</p><nav class="rp-tabs" aria-label="Time range">${tabs}</nav></header>++<section class="rp-tiles" aria-label="Across the network">++${tile(`Page views, ${d.window_days || days} days`, exact(T.pageviews), pct(T.change_pct), spark(d.pageviews_daily))}++${A ? tile('Accounts', exact(A.total), A.new != null ? `+${exact(A.new)} in ${d.window_days || days} days` : '') : ''}++${T.services ? tile('Services up', `${exact(T.services.up)}<small> of ${exact(T.services.checked)}</small>`, '<a href="https://openvibe.network/status">Status</a>') : ''}++${T.requests_24h != null ? tile('Requests, 24 h', fmt(T.requests_24h), T.sites_open ? `${exact(T.sites_open)} sites` : '') : ''}++${L ? tile('Live right now', exact(L.live_now), L.watching != null ? `${exact(L.watching)} watching` : '') : ''}++${C ? tile('OpenCoins earned', fmt(C.earned), C.earned_window != null ? `+${fmt(C.earned_window)} in ${d.window_days || days} days` : '') : ''}++</section>++${d.pageviews_daily && d.pageviews_daily.length > 1 ? `<section aria-labelledby="rp-pv-h"><h2 id="rp-pv-h">Page views a day</h2>${bars(d.pageviews_daily, 'Page views a day')}</section>` : ''}++${sites ? `<section aria-labelledby="rp-sites-h"><h2 id="rp-sites-h">Every site</h2><p class="rp-fine">Most visited first over ${esc(d.window_days || days)} days; requests and speed (95% of answers were at least this fast) over the last 24 hours.</p>++<div class="rp-table" role="table" aria-label="Every site"><div class="rp-tr rp-th" role="row"><span role="columnheader">Site</span><span role="columnheader">Page views</span><span role="columnheader">Visitors</span><span role="columnheader">Requests, 24 h</span><span role="columnheader">95% answered in</span></div>${sites}</div></section>` : ''}++${L ? `<section aria-labelledby="rp-live-h"><h2 id="rp-live-h">OpenVibe.Live</h2><div class="rp-minis">${[['Streamers', L.streamers], ['Streams', L.streams], ['Hours watched', L.hours_watched], ['Chat messages', L.messages], ['Recordings', L.vods], ['Clips', L.clips], ['AI moments', L.ai_moments]].filter(([, v]) => n(v)).map(([l, v]) => `<div><b>${fmt(v)}</b><span>${esc(l)}</span></div>`).join('')}</div></section>` : ''}++${A && A.daily && A.daily.length > 1 ? `<section aria-labelledby="rp-acc-h"><h2 id="rp-acc-h">New accounts a day</h2>${bars(A.daily, 'New accounts a day', { h: 110 })}</section>` : ''}++<section aria-labelledby="rp-how-h"><h2 id="rp-how-h">How these are counted</h2><p class="rp-fine">${esc(d.note || '')} The same numbers: <a href="/api/v1/stats?days=${esc(days)}"><code>/api/v1/stats</code></a>.</p></section>`;++ return layout({ title: `Every site's numbers, ${days} days`, description: 'Page views, people, streams and how fast each OpenVibe service answers, open to everyone.', body, active: '/stats', path: `/stats${Number(days) === 30 ? '' : `?days=${Number(days)}`}` });++}++++function rundown({ rundown: R = {}, topics } = {}) {++ const all = ((topics && topics.topics) || []).slice(0, 24);++ const onAir = (R.on_air || []).map(streamCard).filter(Boolean);++ const voices = (R.voices || []).slice(0, 12);++ const streams = (R.streams || []).slice(0, 12).map(streamCard).filter(Boolean);++ const body = `<header class="rp-page-h"><p class="rp-kicker">The Rundown</p><h1>What streamers talked about</h1>++<p class="rp-lede">Written up from what was said on stream: topics, takes and how they connect. ${AI}</p>++${R.counts ? `<p class="rp-counts">${[['topics', R.counts.topics], ['episodes', R.counts.episodes], ['takes', R.counts.takes]].filter(([, v]) => n(v)).map(([l, v]) => `<span><b>${exact(v)}</b> ${l}</span>`).join('')}</p>` : ''}</header>++${onAir.length ? kit.rail({ id: 'on-air', title: 'On air now', items: onAir }) : ''}++${R.lead ? `<section class="rp-lead" aria-labelledby="rp-lead-h"><p class="rp-kicker">Lead</p><h2 id="rp-lead-h">${esc(R.lead.title)}</h2>${R.lead.what ? `<p class="rp-lead-what">${esc(R.lead.what)}</p>` : ''}${R.lead.take ? `<p class="rp-lead-take">${esc(R.lead.take)}</p>` : ''}</section>` : ''}++${all.length ? `<section aria-labelledby="rp-t-h"><h2 id="rp-t-h">Topics</h2><ul class="rp-topics">${all.map(topicCard).join('')}</ul></section>` : ''}++${voices.length ? `<section aria-labelledby="rp-v-h"><h2 id="rp-v-h">Voices</h2><ul class="rp-voices">${voices.map((v) => `<li><a href="/rundown/s/${encodeURIComponent((v.user || {}).username || '')}">${kit.mark({ name: who(v.user), src: v.user && v.user.avatar_url, size: 40 })}<span><b>${esc(who(v.user))}</b><small>${[n(v.topics) ? `${exact(v.topics)} topics` : '', n(v.segments) ? `${exact(v.segments)} takes` : ''].filter(Boolean).join(' · ')}</small></span></a></li>`).join('')}</ul></section>` : ''}++${streams.length ? kit.rail({ id: 'episodes', title: 'Recent streams', items: streams }) : ''}`;++ return layout({ title: 'The Rundown: what streamers talked about', description: 'Topics, takes and crossovers from what streamers said on OpenVibe, written up by AI from the streams themselves.', body, active: '/rundown', path: '/rundown' });++}++++function topic({ topic: d } = {}) {++ const t = d.topic || {};++ const takes = (d.takes || []).slice(0, 40);++ const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/rundown">The Rundown</a> · ${esc(KIND[t.kind] || t.kind || 'Topic')}</p><h1>${esc(t.title)}</h1>++${t.headline ? `<p class="rp-lede">${esc(t.headline)}</p>` : ''}${t.summary ? `<p class="rp-summary">${esc(t.summary)}</p>` : ''}<p class="rp-meta">${AI}${t.first_seen_at ? ` · since ${esc(day(t.first_seen_at))}` : ''}</p></header>++${takes.length ? `<section aria-labelledby="rp-tk-h"><h2 id="rp-tk-h">Takes</h2><ol class="rp-takes">${takes.map(takeRow).join('')}</ol></section>` : ''}++${(d.related || []).length ? `<section aria-labelledby="rp-rel-h"><h2 id="rp-rel-h">Related</h2><ul class="rp-chips">${d.related.slice(0, 12).map((r) => `<li><a href="/rundown/t/${encodeURIComponent(r.slug)}">${esc(r.title)}</a></li>`).join('')}</ul></section>` : ''}`;++ return layout({ title: `${t.title}: The Rundown`, description: t.headline || `What streamers said about ${t.title}.`, body, active: '/rundown', path: `/rundown/t/${encodeURIComponent(t.slug || '')}` });++}++++function episode({ episode: d } = {}) {++ const e = d.episode || {}, s = d.stream || {};++ const segs = (d.segments || []).slice(0, 60);++ const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/rundown">The Rundown</a> · episode</p><h1>${esc(e.title || s.title || 'Episode')}</h1>${e.logline ? `<p class="rp-lede">${esc(e.logline)}</p>` : ''}++<p class="rp-meta">${AI}${who(s.user) ? ` · <a href="/rundown/s/${encodeURIComponent(s.user.username)}">${esc(who(s.user))}</a>` : ''}${s.started_at ? ` · ${esc(dayTime(s.started_at))}` : ''}${s.id ? ` · <a href="/after-show/${encodeURIComponent(s.id)}">After-show report</a>` : ''}</p></header>++${segs.length ? `<section aria-labelledby="rp-sg-h"><h2 id="rp-sg-h">What happened</h2><ol class="rp-takes rp-timeline">${segs.map(takeRow).join('')}</ol></section>` : ''}++${(d.more || []).length ? `<section aria-labelledby="rp-mo-h"><h2 id="rp-mo-h">More from ${esc(who(s.user) || 'this streamer')}</h2><ul class="rp-chips">${d.more.slice(0, 8).map((m) => `<li><a href="/rundown/e/${encodeURIComponent(m.stream_id)}">${esc(m.title)}</a></li>`).join('')}</ul></section>` : ''}`;++ return layout({ title: `${e.title || s.title}: The Rundown`, description: e.logline || '', body, active: '/rundown', path: `/rundown/e/${encodeURIComponent(s.id || e.stream_id || '')}` });++}++++function speaker({ speaker: d, username } = {}) {++ const u = d.user || { username };++ if (d.opted_out) return layout({ title: `${who(u)}: The Rundown`, robots: 'noindex, follow', body: `<header class="rp-page-h"><h1>${esc(who(u))}</h1><p class="rp-lede">${esc(who(u))} has opted out of The Rundown: nothing they say on stream is written up here.</p></header>`, active: '/rundown' });++ const body = `<header class="rp-page-h rp-person">${kit.mark({ name: who(u), src: u.avatar_url, size: 64 })}<div><p class="rp-kicker">The Rundown · voice</p><h1>${esc(who(u))}</h1><p class="rp-meta"><a href="https://openvibe.live/@${encodeURIComponent(u.username || username)}">Channel on OpenVibe.Live</a> · <a href="/c/${encodeURIComponent(u.username || username)}">After-show reports</a></p></div></header>++${(d.topics || []).length ? `<section aria-labelledby="rp-st-h"><h2 id="rp-st-h">Topics</h2><ul class="rp-topics">${d.topics.slice(0, 12).map(topicCard).join('')}</ul></section>` : ''}++${(d.lines || []).length ? `<section aria-labelledby="rp-sl-h"><h2 id="rp-sl-h">Takes</h2><ol class="rp-takes">${d.lines.slice(0, 20).map(takeRow).join('')}</ol><p class="rp-meta">${AI}</p></section>` : ''}++${(d.episodes || []).length ? `<section aria-labelledby="rp-se-h"><h2 id="rp-se-h">Episodes</h2><ul class="rp-chips">${d.episodes.slice(0, 12).map((m) => `<li><a href="/rundown/e/${encodeURIComponent(m.stream_id)}">${esc(m.title)}</a></li>`).join('')}</ul></section>` : ''}`;++ return layout({ title: `${who(u)}: The Rundown`, description: `What ${who(u)} talked about on stream, topic by topic.`, body, active: '/rundown', path: `/rundown/s/${encodeURIComponent(u.username || username || '')}` });++}++++function recap({ recap: d } = {}) {++ const r = d.recap || d;++ const s = r.stream || {}, u = r.streamer || {}, v = r.viewers || {}, c = r.chat || {}, love = r.love || {}, w = r.write || {}, vod = r.vod;++ const takes = (r.takes || []).filter((t) => t && t.title).sort((a, b) => (n(a.sec) ?? 1e9) - (n(b.sec) ?? 1e9)).slice(0, 8);++ const at = (sec) => (vod && vod.id && n(sec) != null ? `https://openvibe.video/v/${encodeURIComponent(vod.id)}?t=${Math.max(0, Math.floor(Number(sec)))}` : null);++ const body = `<header class="rp-page-h rp-recap-h"><p class="rp-kicker">After-show report</p><h1>${esc(w.headline || s.title || 'Stream')}</h1>++<p class="rp-meta">${kit.mark({ name: who(u), src: u.avatar_url, size: 28 })} <a href="https://openvibe.live/@${encodeURIComponent(u.username || '')}">${esc(who(u))}</a>${s.title && w.headline ? ` · ${esc(s.title)}` : ''}${s.started_at ? ` · ${esc(dayTime(s.started_at))}` : ''}${n(s.duration_seconds) ? ` · ${esc(mins(s.duration_seconds))}` : ''}</p>++${w.summary ? `<p class="rp-summary">${esc(w.summary)}</p><p class="rp-meta">${AI}</p>` : ''}</header>++<section class="rp-tiles" aria-label="The stream in numbers">++${tile('Peak viewers', n(s.peak_viewers) != null ? exact(s.peak_viewers) : null, v.peak_at ? esc(dayTime(v.peak_at)) : '')}++${tile('Average viewers', n(v.avg) != null ? exact(v.avg) : null)}++${tile('Chat messages', n(c.messages) ? exact(c.messages) : null, n(c.chatters) ? `${exact(c.chatters)} chatting` : '')}++${tile('New followers', n(love.follows) ? exact(love.follows) : null, n(love.followers_now) ? `${exact(love.followers_now)} now` : '')}++${tile('Tips', n(love.tips) ? exact(love.tips) : null)}++${tile('Length', n(s.duration_seconds) ? esc(mins(s.duration_seconds)) : null)}++</section>++${curve(v.curve) ? `<section aria-labelledby="rp-cv-h"><h2 id="rp-cv-h">Minute by minute</h2>${curve(v.curve)}</section>` : ''}++${takes.length ? `<section aria-labelledby="rp-rt-h"><h2 id="rp-rt-h">The moments</h2><ol class="rp-moments">${takes.map((t) => `<li>${at(t.sec) ? `<a class="rp-at" href="${esc(at(t.sec))}">${kit.icon('play')}<span>${esc(mins(t.sec) || '0 min')}</span></a>` : ''}<span><b>${esc(t.title)}</b>${t.topic ? `<a class="rp-kind" href="/rundown/t/${encodeURIComponent(t.slug || '')}">${esc(t.topic)}</a>` : ''}</span></li>`).join('')}</ol><p class="rp-meta">${AI}</p></section>` : ''}++${(c.top || []).length ? `<section aria-labelledby="rp-ch-h"><h2 id="rp-ch-h">Most active in chat</h2><ul class="rp-chips">${c.top.slice(0, 8).map((x) => `<li><span>${esc(x.display_name || x.username)} <b>${exact(x.n)}</b></span></li>`).join('')}</ul></section>` : ''}++<p class="rp-act">${vod && vod.id ? `<a class="rp-btn" href="https://openvibe.video/v/${encodeURIComponent(vod.id)}">Watch the recording</a>` : ''} <a class="rp-btn is-quiet" href="/c/${encodeURIComponent(u.username || '')}">More from ${esc(who(u))}</a>${s.id ? ` <a class="rp-btn is-quiet" href="/rundown/e/${encodeURIComponent(s.id)}">In The Rundown</a>` : ''}</p>`;++ return layout({ title: `${w.headline || s.title}: after-show report`, description: w.summary || `How ${who(u)}'s stream went, in numbers.`, body, active: '/rundown', path: `/after-show/${encodeURIComponent(s.id || '')}` });++}++++function channel({ username, recaps } = {}) {++ const list = (recaps && recaps.recaps) || [];++ const cards = list.slice(0, 30).map((x) => kit.card({ href: `/after-show/${x.stream_id}`, title: x.headline || x.title || 'Stream', peek: x.headline && x.title ? x.title : '', type: 'stream', image: x.thumbnail_url || '', badge: x.grade ? `Grade ${x.grade}` : '', corner: n(x.duration_seconds) ? mins(x.duration_seconds) : '', count: n(x.peak_viewers) ? [x.peak_viewers, 'peak'] : null, when: x.ended_at ? ts(x.ended_at) : null }));++ const body = `<header class="rp-page-h"><p class="rp-kicker">After-show reports</p><h1>${esc(username)}</h1><p class="rp-meta"><a href="https://openvibe.live/@${encodeURIComponent(username)}">Channel on OpenVibe.Live</a> · <a href="/rundown/s/${encodeURIComponent(username)}">In The Rundown</a></p></header>++${cards.length ? `<ul class="rp-grid">${cards.map((c) => `<li>${c}</li>`).join('')}</ul>` : kit.empty({ title: 'No reports yet', text: 'A report is written a few minutes after each stream ends.' })}`;++ return layout({ title: `${username}: after-show reports`, description: `How each of ${username}'s streams went.`, body, active: '/rundown', path: `/c/${encodeURIComponent(username)}` });++}++++function developers() {++ const rows = [++ ['GET /api/v1/stats?days=30', 'The network\'s open numbers over 7, 30 or 90 days: page views, accounts, Live, OpenCoins and every site.'],++ ['GET /api/v1/rundown', 'The Rundown\'s front: on air, the lead, recent streams, trending topics, voices and counts.'],++ ['GET /api/v1/rundown/topics', 'Every topic with its headline, takes and streamers.'],++ ['GET /api/v1/rundown/t/:slug', 'One topic with its takes and related topics.'],++ ['GET /api/v1/after-show/:streamId', 'A stream\'s after-show report: viewers minute by minute, chat, moments and the write-up.'],++ ['GET /api/v1/c/:username', 'A channel\'s after-show reports, newest first.'],++ ];++ const body = `<header class="rp-page-h"><p class="rp-kicker">For developers</p><h1>The Report API</h1><p class="rp-lede">Open JSON, no key, readable from any site (CORS). Counts only, never about a person.</p></header>++<section aria-labelledby="rp-ep-h"><h2 id="rp-ep-h">Endpoints</h2><dl class="rp-eps">${rows.map(([ep, what]) => `<div><dt><code>${esc(ep)}</code></dt><dd>${esc(what)}</dd></div>`).join('')}</dl></section>++<p class="rp-fine">Limits: 120 requests a minute from one address. Errors are problem+json (RFC 9457). <a href="/llms.txt">llms.txt</a> describes the site for agents.</p>`;++ return layout({ title: 'The Report API', description: 'Open JSON for the network\'s numbers, The Rundown and after-show reports.', body, active: '/developers', path: '/developers' });++}++++function notFound() {++ return layout({ title: 'Not found', robots: 'noindex, follow', body: `<header class="rp-page-h"><h1>Nothing here</h1><p class="rp-lede">That page does not exist, or its source has nothing for it. <a href="/">Back to Report</a>.</p></header>` });++}++++module.exports = { home, stats, rundown, topic, episode, speaker, recap, channel, developers, notFound, layout, setRelease, configure };nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++++/**++ * The JSON API: every route answers the source's document as it came, CORS-open and public for two minutes; a bad++ * parameter or a missing source is a 404 problem+json without a source call; the per-address limit answers 429.++ */++const assert = require('assert');++const { boot, check, done, defaults } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ try {++ await check('every API route answers the source JSON with CORS and the two-minute cache', async () => {++ const d = defaults();++ const routes = [++ ['/api/v1/stats?days=30', d.stats],++ ['/api/v1/rundown', d.rundown],++ ['/api/v1/rundown/topics', d.topics],++ ['/api/v1/rundown/t/seattle', d.topic],++ ['/api/v1/after-show/7', d.recap],++ ['/api/v1/c/goosely', d.channel],++ ];++ for (const [path, expected] of routes) {++ const r = await t.get(path);++ assert.strictEqual(r.status, 200, path);++ assert.match(r.headers.get('content-type') || '', /^application\/json/);++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=120');++ assert.deepStrictEqual(r.json(), expected, path);++ }++ });++++ await check('a bad parameter or an unknown route is a 404 problem+json, still CORS-open', async () => {++ const before = t.fetchImpl.calls.length;++ for (const path of ['/api/v1/rundown/t/Not_A_Slug', '/api/v1/after-show/0', '/api/v1/c/bad-name', '/api/v1/nope']) {++ const r = await t.get(path);++ assert.strictEqual(r.status, 404, path);++ assert.match(r.headers.get('content-type') || '', /^application\/problem\+json/);++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');++ const body = r.json();++ assert.strictEqual(body.status, 404);++ assert.ok(body.code, 'no problem code');++ assert.ok(body.type, 'no problem type');++ }++ const outside = await t.get('/api/nope');++ assert.strictEqual(outside.status, 404);++ assert.match(outside.headers.get('content-type') || '', /^application\/problem\+json/);++ assert.strictEqual(t.fetchImpl.calls.length, before, 'a bad parameter reached the source');++ });++++ await check('the API is read-only', async () => {++ const r = await t.get('/api/v1/stats', { method: 'POST' });++ assert.strictEqual(r.status, 405);++ assert.match(r.headers.get('content-type') || '', /^application\/problem\+json/);++ assert.strictEqual(r.headers.get('allow'), 'GET, HEAD');++ });++++ await check('a source that answers nothing is a 404 problem+json', async () => {++ const empty = await boot({ data: { stats: null, recap: null } });++ try {++ for (const path of ['/api/v1/stats', '/api/v1/after-show/7']) {++ const r = await empty.get(path);++ assert.strictEqual(r.status, 404, path);++ assert.match(r.headers.get('content-type') || '', /^application\/problem\+json/);++ }++ } finally { await empty.close(); }++ });++ } finally {++ await t.close();++ }++++ await check('120 (or the configured number of) requests a minute per address, then a 429 problem', async () => {++ const limited = await boot({ env: { REPORT_API_RATE_LIMIT_PER_MIN: '2' } });++ try {++ assert.strictEqual((await limited.get('/api/v1/rundown/topics')).status, 200);++ assert.strictEqual((await limited.get('/api/v1/rundown/topics')).status, 200);++ const third = await limited.get('/api/v1/rundown/topics');++ assert.strictEqual(third.status, 429);++ assert.match(third.headers.get('content-type') || '', /^application\/problem\+json/);++ assert.strictEqual(third.json().code, 'rate_limited');++ assert.strictEqual(third.headers.get('access-control-allow-origin'), '*');++ } finally { await limited.close(); }++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,159 @@++'use strict';++++/**++ * The two source clients: a cache hit is one fetch, an expiry is another, a failure is null with one short log++ * line, and a path parameter that cannot exist is refused before any fetch. The clock is injected; no test waits.++ */++const assert = require('assert');++const { createNetworkClient } = require('../server/data/network');++const { createLiveClient } = require('../server/data/live');++const { isSlug, isStreamId, isUsername, normaliseDays } = require('../server/data/source');++const { jsonResponse, check, done } = require('./helpers/boot');++++const quiet = () => ({ warn() {}, error() {} });++++(async () => {++ await check('network stats: the same window twice is one fetch, and the entry expires after five minutes', async () => {++ let now = 1_000_000;++ let calls = 0;++ const client = createNetworkClient({++ config: { networkUrl: 'http://network' },++ fetchImpl: async () => { calls++; return jsonResponse({ window_days: 7, totals: { pageviews: 1 } }); },++ now: () => now,++ log: quiet(),++ });++ const first = await client.stats(7);++ const second = await client.stats(7);++ assert.deepStrictEqual(first, { window_days: 7, totals: { pageviews: 1 } });++ assert.strictEqual(first, second);++ assert.strictEqual(calls, 1, 'a cache hit must not fetch');++ now += 5 * 60_000 + 1;++ await client.stats(7);++ assert.strictEqual(calls, 2, 'an expired entry must fetch again');++ });++++ await check('network stats: days other than 7, 30 or 90 become 30, in the request and in the cache key', async () => {++ const urls = [];++ const client = createNetworkClient({++ config: { networkUrl: 'http://network' },++ fetchImpl: async (url) => { urls.push(String(url)); return jsonResponse({ window_days: 30 }); },++ now: () => 0,++ log: quiet(),++ });++ await client.stats('90');++ await client.stats(31);++ await client.stats('nonsense');++ assert.deepStrictEqual(urls, ['http://network/api/v1/stats?days=90', 'http://network/api/v1/stats?days=30']);++ assert.strictEqual(normaliseDays('7'), 7);++ assert.strictEqual(normaliseDays(30), 30);++ assert.strictEqual(normaliseDays('31'), 30);++ });++++ await check('a source failure is null, never a throw, with one short log line', async () => {++ const lines = [];++ const client = createLiveClient({++ config: { liveUrl: 'http://live' },++ fetchImpl: async () => new Response('down', { status: 503 }),++ log: { warn: (line) => lines.push(String(line)), error: (line) => lines.push(String(line)) },++ });++ assert.strictEqual(await client.rundown(), null);++ assert.strictEqual(lines.length, 1);++ assert.match(lines[0], /\[OpenVibe\.Report\] live rundown failed: HTTP 503/);++++ const thrown = createLiveClient({++ config: { liveUrl: 'http://live' },++ fetchImpl: async () => { throw new Error('connect ECONNREFUSED'); },++ log: { warn: (line) => lines.push(String(line)) },++ });++ assert.strictEqual(await thrown.recap(12), null);++ assert.strictEqual(lines.length, 2);++ assert.match(lines[1], /live recap failed: connect ECONNREFUSED/);++ });++++ await check('live: a slug, stream id or username that cannot exist is refused before any fetch', async () => {++ let calls = 0;++ const client = createLiveClient({++ config: { liveUrl: 'http://live' },++ fetchImpl: async () => { calls++; return jsonResponse({}); },++ log: quiet(),++ });++ assert.strictEqual(await client.topic('UPPER'), null);++ assert.strictEqual(await client.topic(''), null);++ assert.strictEqual(await client.topic('a/b'), null);++ assert.strictEqual(await client.topic('a'.repeat(81)), null);++ assert.strictEqual(await client.episode(0), null);++ assert.strictEqual(await client.episode(-1), null);++ assert.strictEqual(await client.episode('1.5'), null);++ assert.strictEqual(await client.episode('abc'), null);++ assert.strictEqual(await client.episode(null), null);++ assert.strictEqual(await client.speaker('bad name'), null);++ assert.strictEqual(await client.speaker('a'.repeat(33)), null);++ assert.strictEqual(await client.speaker('émile'), null);++ assert.strictEqual(await client.recap(0), null);++ assert.strictEqual(await client.recap('x'), null);++ assert.strictEqual(await client.channelRecaps('bad!'), null);++ assert.strictEqual(calls, 0, 'a bad parameter must never reach the source');++ assert.strictEqual(isSlug('a-valid-slug'), true);++ assert.strictEqual(isStreamId('42'), true);++ assert.strictEqual(isStreamId('0'), false);++ assert.strictEqual(isUsername('goosely'), true);++ });++++ await check('live: each endpoint has its own TTL and its own URL', async () => {++ let now = 0;++ const urls = [];++ const client = createLiveClient({++ config: { liveUrl: 'http://live' },++ fetchImpl: async (url) => { urls.push(String(url)); return jsonResponse({ n: urls.length }); },++ now: () => now,++ log: quiet(),++ });++ await client.rundown();++ now += 2 * 60_000 + 1;++ await client.rundown();++ assert.strictEqual(urls.filter((u) => u.endsWith('/api/rundown/')).length, 2, 'the home is held for two minutes');++++ await client.recap(7);++ now += 9 * 60_000;++ await client.recap(7);++ assert.strictEqual(urls.filter((u) => u.endsWith('/api/recap/7')).length, 1, 'a recap is held for ten minutes');++ now += 2 * 60_000;++ await client.recap(7);++ assert.strictEqual(urls.filter((u) => u.endsWith('/api/recap/7')).length, 2);++++ await client.topic('seattle');++ await client.episode('007');++ await client.speaker('Goosely');++ await client.channelRecaps('goosely');++ assert.ok(urls.includes('http://live/api/rundown/t/seattle'));++ assert.ok(urls.includes('http://live/api/rundown/e/7'), 'a stream id is normalised to a positive integer');++ assert.ok(urls.includes('http://live/api/rundown/s/Goosely'));++ assert.ok(urls.includes('http://live/api/recap/channel/goosely'));++++ // speaker is cached by the lower-cased name: the same person under another case is one fetch.++ const before = urls.filter((u) => u.includes('/api/rundown/s/')).length;++ await client.speaker('goosely');++ assert.strictEqual(urls.filter((u) => u.includes('/api/rundown/s/')).length, before);++ });++++ await check('the cache keeps 1000 entries and drops the oldest first', async () => {++ let calls = 0;++ const client = createLiveClient({++ config: { liveUrl: 'http://live' },++ fetchImpl: async () => { calls++; return jsonResponse({ topic: { slug: 'x' }, takes: [] }); },++ now: () => 0,++ log: quiet(),++ cacheMax: 2,++ });++ await client.topic('one');++ await client.topic('two');++ await client.topic('three');++ assert.strictEqual(client.cache.size, 2);++ assert.strictEqual(client.cache.get('rundown:topic:one'), undefined);++ assert.notStrictEqual(client.cache.get('rundown:topic:two'), undefined);++ assert.notStrictEqual(client.cache.get('rundown:topic:three'), undefined);++ assert.strictEqual(calls, 3);++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++++/**++ * The crawl artifacts: robots.txt allows the public pages and disallows the API; sitemap.xml is the home, /stats,++ * /rundown and every topic from the Rundown; llms.txt names the site and its machine-readable answers. All three++ * are public for an hour.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./helpers/boot');++++const STATUS_UPDATED = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'STATUS.json'), 'utf8')).updated;++++(async () => {++ const t = await boot();++ try {++ await check('robots.txt: public pages crawlable, /api/ disallowed, the sitemap named', async () => {++ const r = await t.get('/robots.txt');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type') || '', /^text\/plain/);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=3600');++ assert.match(r.text, /^User-agent: \*$/m);++ assert.ok(r.text.includes('Disallow: /api/'));++ assert.ok(r.text.includes('Sitemap: https://openvibe.report/sitemap.xml'));++ for (const p of ['/stats', '/rundown']) assert.ok(!r.text.includes(`Disallow: ${p}`), `${p} must stay crawlable`);++ });++++ await check('sitemap.xml: the home, /stats, /rundown and every topic, with lastmod from STATUS.json', async () => {++ const r = await t.get('/sitemap.xml');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type') || '', /^application\/xml/);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=3600');++ for (const p of ['/', '/stats', '/rundown', '/rundown/t/seattle', '/rundown/t/ai-progress']) {++ assert.ok(r.text.includes(`<loc>https://openvibe.report${p}</loc>`), `no ${p} entry`);++ }++ assert.ok(!r.text.includes('<loc>https://openvibe.report/api/'), 'the API must not be in the sitemap');++ const lastmods = [...r.text.matchAll(/<lastmod>([^<]+)<\/lastmod>/g)].map((m) => m[1]);++ assert.ok(lastmods.length > 0, 'no lastmod');++ assert.ok(lastmods.every((d) => d === STATUS_UPDATED), `lastmod must come from STATUS.json (${STATUS_UPDATED})`);++ });++++ await check('llms.txt: the site, its pages and its machine-readable answers', async () => {++ const r = await t.get('/llms.txt');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type') || '', /^text\/plain/);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=3600');++ assert.ok(r.text.startsWith('# OpenVibe.Report'));++ for (const link of [++ 'https://openvibe.report/',++ 'https://openvibe.report/stats',++ 'https://openvibe.report/rundown',++ 'https://openvibe.report/api/v1/stats?days=7',++ 'https://openvibe.report/sitemap.xml',++ ]) assert.ok(r.text.includes(`(${link})`), `missing link ${link}`);++ });++++ await check('a topics source that answers nothing still leaves the three static sitemap pages', async () => {++ const empty = await boot({ data: { topics: null } });++ try {++ const r = await empty.get('/sitemap.xml');++ assert.strictEqual(r.status, 200);++ for (const p of ['/', '/stats', '/rundown']) assert.ok(r.text.includes(`<loc>https://openvibe.report${p}</loc>`), p);++ assert.ok(!r.text.includes('/rundown/t/'), 'no topic should be listed');++ } finally { await empty.close(); }++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++++/**++ * The plumbing: health, truthful readiness (both sources optional, so a dead source is degraded and still 200, never++ * 503), release metadata, and the content-addressed static assets.++ */++const assert = require('assert');++const { assetVersion } = require('../server/assets');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const healthy = await boot();++ try {++ await check('GET /api/health: ok, the service id and the version', async () => {++ const r = await healthy.get('/api/health');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');++ assert.deepStrictEqual(r.json(), { status: 'ok', service: 'openvibe-report', version: '0.1.0' });++ });++++ await check('GET /api/ready: both sources answer → ready, no database check', async () => {++ const r = await healthy.get('/api/ready');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');++ const body = r.json();++ assert.strictEqual(body.ready, true);++ assert.strictEqual(body.status, 'ready');++ assert.strictEqual(body.service, 'report');++ assert.deepStrictEqual(body.degraded, []);++ assert.strictEqual(body.checks.network.status, 'ok');++ assert.strictEqual(body.checks.live.status, 'ok');++ assert.ok(!('db' in body.checks), 'Report has no database');++ });++++ await check('GET /release.json: service report, versioned like every OpenVibe site', async () => {++ const r = await healthy.get('/release.json');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type') || '', /^application\/json/);++ const body = r.json();++ assert.strictEqual(body.service, 'report');++ assert.strictEqual(body.release, '0000000');++ assert.ok(body.packages && body.packages['openvibe-shared'], 'no openvibe-shared version');++ });++++ await check('static css and js: public, immutable only with the matching ?v=', async () => {++ for (const rel of ['css/app.css', 'js/app.js']) {++ const plain = await healthy.get(`/${rel}`);++ assert.strictEqual(plain.status, 200);++ assert.strictEqual(plain.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400');++ const pinned = await healthy.get(`/${rel}?v=${assetVersion(rel)}`);++ assert.strictEqual(pinned.status, 200);++ assert.strictEqual(pinned.headers.get('cache-control'), 'public, max-age=31536000, immutable');++ }++ });++ } finally { await healthy.close(); }++++ await check('GET /api/ready: a source that does not answer is degraded, not 503', async () => {++ const down = await boot({ fetchImpl: async () => { throw new Error('ECONNREFUSED'); } });++ try {++ const r = await down.get('/api/ready');++ assert.strictEqual(r.status, 200, 'a dead optional source must not make the service not_ready');++ const body = r.json();++ assert.strictEqual(body.ready, true);++ assert.strictEqual(body.status, 'degraded');++ assert.deepStrictEqual(body.failed, []);++ assert.deepStrictEqual(body.degraded.sort(), ['live', 'network']);++ assert.strictEqual(body.checks.network.status, 'fail');++ assert.strictEqual(body.checks.live.status, 'fail');++ } finally { await down.close(); }++ });++++ await check('GET /api/ready: one source down is degraded, the other still ok', async () => {++ const partial = await boot({ stub: { health: { network: true, live: false } } });++ try {++ const body = (await partial.get('/api/ready')).json();++ assert.strictEqual(body.ready, true);++ assert.strictEqual(body.status, 'degraded');++ assert.deepStrictEqual(body.degraded, ['live']);++ assert.strictEqual(body.checks.network.status, 'ok');++ } finally { await partial.close(); }++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,208 @@++'use strict';++++// Many requests per test file would trip the 120/min per-address limit. The app's limit is configurable, so the++// tests raise it once through the environment the helper passes to config.load.++process.env.RELEASE_COMMIT = process.env.RELEASE_COMMIT || '0000000';++++/**++ * Boots OpenVibe.Report on an ephemeral port with a stub fetch (no network, no running site) and returns a small++ * HTTP client. Each test file gets its own app, clients and captured log lines.++ *++ * const t = await boot();++ * t.get('/api/v1/stats?days=30') → { status, headers, text, json() }++ * t.logs()++ */++const http = require('http');++const configLib = require('../../server/config');++const { createApp } = require('../../server/app');++++const NETWORK = 'http://127.0.0.1:4000';++const LIVE = 'http://127.0.0.1:3000';++++function jsonResponse(body, status = 200) {++ return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });++}++++function defaults() {++ return {++ stats: {++ generated_at: '2026-10-28T12:00:00.000Z',++ window_days: 7,++ totals: {++ pageviews: 77408, pageviews_prev: 102633, change_pct: -25, sites_open: 37,++ services: { up: 38, checked: 38 }, accounts: 496, requests_24h: 1037412,++ },++ sites: [{ name: 'OpenVibe.Live', host: 'openvibe.live', pageviews: 32000, change_pct: 4 }],++ },++ rundown: {++ on_air: [{ stream_id: 7, user: { username: 'goosely', display_name: 'goosely' }, stream_title: 'Seattle', topic: { slug: 'seattle', title: 'Seattle' } }],++ lead: { title: 'A lead take', what: 'What the lead said.', user: { username: 'goosely', display_name: 'goosely' }, topic: { slug: 'seattle', title: 'Seattle' }, stream_id: 7 },++ streams: [{ stream_id: 7, user: { username: 'goosely', display_name: 'goosely' }, title: 'Seattle', episode: { title: 'A written-up stream' } }],++ trending: [{ slug: 'seattle', title: 'Seattle', kind: 'life', takes: 3, streamers: 1 }],++ counts: { topics: 5, episodes: 4, takes: 20 },++ },++ topics: {++ topics: [++ { slug: 'seattle', title: 'Seattle', kind: 'life', takes: 3, streamers: 1 },++ { slug: 'ai-progress', title: 'AI progress', kind: 'tech', takes: 5, streamers: 2 },++ ],++ kinds: ['news', 'games', 'tech', 'life'],++ },++ topic: {++ topic: { slug: 'seattle', title: 'Seattle', kind: 'life', headline: 'A headline', summary: 'A summary.', takes: 3, streamers: 1, first_seen_at: '2026-10-20 10:00:00', last_seen_at: '2026-10-27 10:00:00' },++ takes: [{ title: 'A take', quip: 'A quip.', user: { username: 'goosely', display_name: 'goosely' }, topic: { slug: 'seattle', title: 'Seattle' }, stream_id: 7 }],++ related: [{ slug: 'ai-progress', title: 'AI progress', kind: 'tech' }],++ },++ episode: {++ episode: { stream_id: 7, title: 'A written-up stream', logline: 'The logline.', minutes: 34, aired_at: '2026-10-27 10:00:00' },++ stream: { id: 7, title: 'Seattle', is_live: false, user: { username: 'goosely', display_name: 'goosely' } },++ segments: [{ title: 'A segment', user: { username: 'goosely', display_name: 'goosely' }, topic: { slug: 'seattle', title: 'Seattle' } }],++ more: [{ stream_id: 6, title: 'An older episode', aired_at: '2026-10-26 10:00:00' }],++ },++ speaker: {++ user: { username: 'goosely', display_name: 'goosely' },++ opted_out: false,++ episodes: [{ stream_id: 7, title: 'A written-up stream', aired_at: '2026-10-27 10:00:00', minutes: 34 }],++ topics: [{ slug: 'seattle', title: 'Seattle', takes: 3 }],++ lines: [],++ crossovers: [],++ },++ recap: {++ recap: {++ stream: { id: 7, title: 'Seattle Today', duration_seconds: 2017, ended_at: '2026-10-27 10:33:00' },++ streamer: { username: 'goosely', display_name: 'goosely' },++ viewers: { avg: 1 },++ chat: { messages: 53, chatters: 2 },++ write: { headline: 'A tiny but mighty vibe', summary: 'What happened, in a paragraph.' },++ generated_at: '2026-10-27T10:35:00.000Z',++ },++ more: [],++ },++ channel: {++ recaps: [{ stream_id: 7, title: 'Seattle Today', headline: 'A tiny but mighty vibe', grade: 'A', duration_seconds: 2017, ended_at: '2026-10-27 10:33:00' }],++ },++ };++}++++/**++ * A URL-aware stand-in for OpenVibe.Network and OpenVibe.Live. `data` overrides any of the default answers; a++ * null answer becomes a 404, exactly as a source that has nothing. `health` says which source's /api/health++ * answers: { network: true, live: false } makes only Live fail.++ */++function stubFetch(data = {}, { health = { network: true, live: true } } = {}) {++ const answers = { ...defaults(), ...data };++ const calls = [];++++ const fetchImpl = async (url) => {++ const raw = String(url);++ calls.push(raw);++ const u = new URL(raw);++ const p = u.pathname;++ const port = u.port;++++ if (p.endsWith('/api/health')) {++ const source = port === '3000' ? 'live' : 'network';++ if (!health[source]) throw new Error(`${source} health failed`);++ return jsonResponse({ status: 'ok' });++ }++++ if (p.endsWith('/api/v1/stats')) {++ return answers.stats == null ? jsonResponse({ error: 'unavailable' }, 404) : jsonResponse(answers.stats);++ }++ if (p.endsWith('/api/rundown/')) {++ return answers.rundown == null ? jsonResponse({ error: 'unavailable' }, 404) : jsonResponse(answers.rundown);++ }++ if (p.endsWith('/api/rundown/topics')) {++ return answers.topics == null ? jsonResponse({ error: 'unavailable' }, 404) : jsonResponse(answers.topics);++ }++ let m = /^\/api\/rundown\/t\/([^/]+)$/.exec(p);++ if (m) return answers.topic == null ? jsonResponse({ error: 'not found' }, 404) : jsonResponse(answers.topic);++ m = /^\/api\/rundown\/e\/(\d+)$/.exec(p);++ if (m) return answers.episode == null ? jsonResponse({ error: 'not found' }, 404) : jsonResponse(answers.episode);++ m = /^\/api\/rundown\/s\/([^/]+)$/.exec(p);++ if (m) return answers.speaker == null ? jsonResponse({ error: 'not found' }, 404) : jsonResponse(answers.speaker);++ m = /^\/api\/recap\/channel\/([^/]+)$/.exec(p);++ if (m) return answers.channel == null ? jsonResponse({ error: 'not found' }, 404) : jsonResponse(answers.channel);++ m = /^\/api\/recap\/(\d+)$/.exec(p);++ if (m) return answers.recap == null ? jsonResponse({ error: 'not found' }, 404) : jsonResponse(answers.recap);++++ return jsonResponse({ error: 'no stub for ' + p }, 404);++ };++ fetchImpl.calls = calls;++ return fetchImpl;++}++++async function boot(opts = {}) {++ const captured = [];++ const log = {++ log: (...a) => captured.push(a.map(String).join(' ')),++ info: (...a) => captured.push(a.map(String).join(' ')),++ warn: (...a) => captured.push(a.map(String).join(' ')),++ error: (...a) => captured.push(a.map(String).join(' ')),++ };++ const fetchImpl = opts.fetchImpl || stubFetch(opts.data, opts.stub);++ const env = {++ NODE_ENV: 'test',++ PORT: '0',++ BASE_URL: 'https://openvibe.report',++ TRUST_PROXY: '1',++ OV_NETWORK_INTERNAL_URL: NETWORK,++ OV_LIVE_INTERNAL_URL: LIVE,++ REPORT_API_RATE_LIMIT_PER_MIN: '100000',++ ...(opts.env || {}),++ };++ const config = configLib.load(env);++ const built = await createApp({++ config,++ fetchImpl,++ log,++ now: opts.now,++ releaseEnv: { ...process.env, RELEASE_COMMIT: '0000000' },++ });++ 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}`;++++ async function get(pathname, o = {}) {++ const res = await fetch(base + pathname, {++ method: o.method || 'GET',++ headers: o.headers || {},++ redirect: 'manual',++ });++ const text = await res.text();++ return {++ status: res.status,++ headers: res.headers,++ text,++ json() { return JSON.parse(text); },++ };++ }++++ return {++ base, config, ctx: built.ctx, fetchImpl, get,++ logs: () => captured.join('\n'),++ async close() {++ built.ctx.metricsStop();++ await new Promise((resolve) => server.close(resolve));++ },++ };++}++++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, stubFetch, jsonResponse, defaults, NETWORK, LIVE };nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++++/**++ * Every page route: 200 with stub data, its own canonical and the two-minute public cache; a bad path parameter is a++ * 404 without a source call; a source that answers nothing is a 404 (the home leaves that part out); every dynamic value is escaped.++ */++const assert = require('assert');++const { boot, check, done, defaults } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ try {++ const pages = [++ ['/', ['OpenVibe.Report', '77,408', 'A lead take', 'Seattle']],++ ['/stats?days=30', ['Every OpenVibe site', '30 days', '77,408', 'OpenVibe.Live']],++ ['/rundown', ['The Rundown', 'goosely', 'AI progress', 'A written-up stream']],++ ['/rundown/t/seattle', ['Seattle', 'A headline', 'A take']],++ ['/rundown/e/7', ['A written-up stream', 'The logline.', 'A segment']],++ ['/rundown/s/goosely', ['goosely', 'A written-up stream', 'Seattle']],++ ['/after-show/7', ['Seattle Today', 'After-show report']],++ ['/c/goosely', ['goosely', 'Seattle Today', 'A tiny but mighty vibe', 'Grade A']],++ ];++ for (const [path, expected] of pages) {++ await check(`GET ${path}: 200 text/html, public for two minutes, the model in the page`, async () => {++ const r = await t.get(path);++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type') || '', /^text\/html/);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=120');++ assert.ok(r.text.startsWith('<!doctype html>'), 'not a whole document');++ assert.match(r.text, /<meta name="robots" content="index, follow">/);++ for (const value of expected) assert.ok(r.text.includes(value), `missing ${value}`);++ });++ }++++ await check('a page names its canonical and mounts the Frame for service report', async () => {++ const r = await t.get('/rundown/t/seattle');++ assert.ok(r.text.includes('<link rel="canonical" href="https://openvibe.report/rundown/t/seattle">'));++ assert.ok(r.text.includes('<div id="navbar-mount"></div>'), 'no navbar mount');++ assert.ok(r.text.includes('id="ov-footer"'), 'no Shared footer');++ assert.ok(r.text.includes('"service":"report"'), 'the Frame is not initialised for report');++ assert.ok(r.text.includes('theme-loader.js'), 'no theme loader');++ assert.ok(r.text.includes('navbar.js') && r.text.includes('footer.js'), 'no Shared Frame scripts');++ assert.ok(r.text.includes('OpenVibeFooter.init'), 'no footer init');++ });++++ await check('unknown paths are the 404 view with noindex and the page cache', async () => {++ const r = await t.get('/not-a-page');++ assert.strictEqual(r.status, 404);++ assert.match(r.headers.get('content-type') || '', /^text\/html/);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=120');++ assert.ok(r.text.includes('<h1>Nothing here</h1>'));++ assert.match(r.text, /<meta name="robots" content="noindex[^"]*">/);++ });++++ await check('a bad slug, stream id or username is a 404 before the source is called', async () => {++ const before = t.fetchImpl.calls.length;++ for (const path of ['/rundown/t/Not_A_Slug', '/rundown/t/' + 'a'.repeat(81), '/rundown/e/0', '/rundown/e/abc', '/rundown/s/bad-name', '/after-show/-1', '/c/bad!']) {++ const r = await t.get(path);++ assert.strictEqual(r.status, 404, path);++ }++ const after = t.fetchImpl.calls.slice(before);++ assert.strictEqual(after.length, 0, `a bad parameter reached the source: ${after.join(', ')}`);++ });++++ await check('days other than 7, 30 or 90 render the 30-day page', async () => {++ const r = await t.get('/stats?days=365');++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('aria-current="page">30 days'), 'the 30-day tab is the open one');++ const request = t.fetchImpl.calls.filter((u) => u.includes('/api/v1/stats')).pop();++ assert.ok(request.endsWith('?days=30'), request);++ });++ } finally {++ await t.close();++ }++++ await check('a source that answers nothing makes the page a 404', async () => {++ const empty = await boot({ data: { rundown: null, topic: null } });++ try {++ assert.strictEqual((await empty.get('/rundown')).status, 404);++ assert.strictEqual((await empty.get('/rundown/t/seattle')).status, 404);++ // The home stands on its parts: with the Rundown down it still answers, without that part.++ const home = await empty.get('/');++ assert.strictEqual(home.status, 200);++ assert.ok(!home.text.includes('What streamers talked about') || home.text.includes('rp-topics'), 'no empty Rundown part');++ assert.strictEqual((await empty.get('/stats')).status, 200, 'an unrelated page still serves');++ } finally { await empty.close(); }++ });++++ await check('every dynamic value is escaped', async () => {++ const data = defaults();++ data.topic.topic.title = '<script>alert(1)</script>';++ data.topic.topic.headline = '"quoted" & <b>bold</b>';++ const xss = await boot({ data });++ try {++ const r = await xss.get('/rundown/t/seattle');++ assert.strictEqual(r.status, 200);++ assert.ok(!r.text.includes('<script>alert(1)</script>'), 'an unescaped script reached the page');++ assert.ok(r.text.includes('<script>alert(1)</script>'));++ assert.ok(r.text.includes('"quoted" & <b>bold</b>'));++ } finally { await xss.close(); }++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });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 stub both source APIs, so no test needs++ * the network or a running OpenVibe site.++ *++ * npm test # everything++ * npm test -- stats # 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: 60000, 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,21 @@
@@ -0,0 +1,21 @@
+# OpenVibe.Report — environment (production: /etc/openvibe/report.env). Names only; never commit values.
+
+NODE_ENV=production
+PORT=5080
+HOST=127.0.0.1
+BASE_URL=https://openvibe.report
+TRUST_PROXY=2
+
+# OpenVibe.Network's open stats (GET /api/v1/stats?days=7|30|90). Both sources serve these read APIs on loopback;
+# the defaults are the internalOrigin values in openvibe-contracts' manifests/services/{network,live}.json.
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+
+# OpenVibe.Live's Rundown and after-show reports (GET /api/rundown/*, /api/recap/*).
+OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
+
+# Optional: the per-address API limit (default 120 a minute) and the readiness probe timeout (default 2500 ms).
+#REPORT_API_RATE_LIMIT_PER_MIN=120
+#REPORT_READY_TIMEOUT_MS=2500
+
+# Optional: the deployed commit for /release.json. Without it the release is read from git in the checkout.
+#RELEASE_COMMIT=
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ # install, node --check and npm test: the shared test job (Track Q). Report has no database and no services to
+ # start. The contracts check is skipped until a report service manifest is registered in openvibe-contracts.
+ test:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
+ with:
+ syntax-dirs: server test public
+ test: npm test
+ # 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/
+.npm-cache/
+.ref/
+.env
+.env.*
+!.env.example
+*.log
+*.pem
+*.key
+coverage/
+.DS_Store
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+# Changelog
+
+What changed in OpenVibe.Report, newest first. Each site also publishes its patch notes at /updates on
+openvibe.network.
+
+## 0.1.0 — 2026-10-28
+
+The service skeleton: a stateless front for the network's open numbers, The Rundown and every stream's after-show
+report.
+
+- **Two read clients** (`server/data/network.js`, `server/data/live.js`): OpenVibe.Network's open stats and
+ OpenVibe.Live's Rundown and recap APIs, with injectable fetch, 8 s timeouts, an in-memory cache (1000 entries,
+ oldest out; 2 to 10 minutes per answer) and one short log line on failure. A failure is null, never a throw.
+ Slugs, stream ids and usernames are validated before a source request; a bad parameter is a 404 without one.
+- **Pages** (`server/http/pages.js`, `server/views/index.js`): `/`, `/stats`, `/rundown`, `/rundown/t/:slug`,
+ `/rundown/e/:streamId`, `/rundown/s/:username`, `/after-show/:streamId` and `/c/:username`. The views are plain
+ placeholders with every dynamic value escaped; the main session designs the real pages. A missing source answer
+ is the 404 view.
+- **JSON API** (`server/http/api.js`): `/api/v1/stats`, `/api/v1/rundown`, `/api/v1/rundown/topics`,
+ `/api/v1/rundown/t/:slug`, `/api/v1/after-show/:streamId` and `/api/v1/c/:username` answer the source's document
+ as it came, CORS-open and public for two minutes; 120 requests a minute per address; problem+json 404s.
+- **Plumbing**: helmet with the same CSP as OpenVibe.Help, `/api/health`, `/api/ready` (Network and Live are
+ optional, so a dead source is degraded and still 200), `/release.json`, `/release-metrics`, `/metrics` on
+ loopback, `/shared/*`, `/css` and `/js` (immutable only with the matching `?v=`), `robots.txt`, `sitemap.xml`
+ (the home, `/stats`, `/rundown` and every topic), `llms.txt`, and the SDK's graceful stop.
+- **Deploy**: `deploy/systemd/openvibe-report.service` on 127.0.0.1:5080 and
+ `deploy/nginx/openvibe.report.conf` (www → apex, Let's Encrypt certs, GET-only `/api/`), plus `STATUS.json`,
+ `.env.example` and the shared CI workflow.
+- **Tests** (`test/`): the clients' cache, expiry, failure and parameter rules; every page and its 404s; the API
+ shapes, CORS, cache and rate limit; robots/sitemap/llms; health, readiness and the static cache.
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,125 @@
@@ -0,0 +1,125 @@
+# OpenVibe.Report
+
+> Every number on OpenVibe, honest.
+
+**Status:** v0.1.0 service skeleton (see [STATUS.json](STATUS.json)). The routes, the API and the plumbing are
+complete; the views are plain placeholders the main session will design.
+**Domain:** `openvibe.report` · **Port:** 5080 · **Service id:** `report` · **Env prefix:** `OV_` / `REPORT_`
+**License:** AGPL-3.0 (same as every OpenVibe service).
+
+The Report room of the OpenVibe network: the open numbers every site publishes, The Rundown of what streamers talked
+about, and each stream's after-show report.
+
+## Purpose
+
+OpenVibe.Report is a stateless read-only front. It has no database, no sign-in and no events. It asks two loopback
+APIs for their public answers, holds each answer in this process for a few minutes, and renders it:
+
+| Source | Read | Used for |
+|---|---|---|
+| **OpenVibe.Network** | `GET /api/v1/stats?days=7\|30\|90` | `/`, `/stats`, `GET /api/v1/stats` |
+| **OpenVibe.Live** — The Rundown | `GET /api/rundown/`, `/api/rundown/topics`, `/api/rundown/t/:slug`, `/api/rundown/e/:streamId`, `/api/rundown/s/:username` | `/rundown`, `/rundown/t/:slug`, `/rundown/e/:streamId`, `/rundown/s/:username` |
+| **OpenVibe.Live** — after-show reports | `GET /api/recap/:streamId`, `GET /api/recap/channel/:username` | `/after-show/:streamId`, `/c/:username` |
+
+A source that does not answer is null, after one short log line; the page (or API route) becomes a 404. The service
+never invents a zero and never throws a source error at the reader.
+
+## Does not own
+
+- **The numbers** — OpenVibe.Network's open stats. Report quotes them.
+- **The Rundown and the after-show reports** — OpenVibe.Live. Report quotes them, including their counts and text.
+- **Identity, sessions and writes** — Report has none. It does not read cookies and has no POST routes.
+- **The Shared Frame** — `openvibe-shared` (navbar, footer, theme-loader, static files), served from this site's own
+ pinned copy at `/shared/*`.
+
+## Pages
+
+All server-rendered placeholders in `server/views/index.js`; every dynamic value goes through `esc()`.
+
+| Page | What it shows |
+|---|---|
+| `/` | the 7-day open numbers, the Rundown's counts and lead, and the trending topics |
+| `/stats?days=7\|30\|90` | the network totals and a first look at every site (anything else is 30) |
+| `/rundown` | on air, the lead, the latest streams and every topic |
+| `/rundown/t/:slug` | one topic, its takes and related topics |
+| `/rundown/e/:streamId` | one stream as an episode, with its segments |
+| `/rundown/s/:username` | one streamer's episodes and topics |
+| `/after-show/:streamId` | one after-show report |
+| `/c/:username` | one channel's latest after-show reports |
+
+A bad path parameter (slug, stream id, username) or a source that answered nothing renders `views.notFound()` with
+status 404. Pages are `Cache-Control: public, max-age=120`.
+
+## API
+
+All under `/api/v1`, CORS `*`, `Cache-Control: public, max-age=120`, 120 requests a minute per address, and the
+source's answer as it came:
+
+```
+GET /api/v1/stats?days=7|30|90
+GET /api/v1/rundown
+GET /api/v1/rundown/topics
+GET /api/v1/rundown/t/:slug
+GET /api/v1/after-show/:streamId
+GET /api/v1/c/:username
+```
+
+A bad parameter or a missing source answer is a 404 `application/problem+json` body built with
+`openvibe-contracts` `http.sendProblem`. The API is read-only; other methods get 405.
+
+## Crawl artifacts
+
+- `/robots.txt` — the public pages are crawlable; `/api/` is not; the sitemap is named.
+- `/sitemap.xml` — `/`, `/stats`, `/rundown` and every `/rundown/t/<slug>` from the Rundown's topics (the three
+ static pages remain when the topics source is down). `lastmod` comes from `STATUS.json`.
+- `/llms.txt` — the site, its pages and its machine-readable answers.
+
+## Configuration
+
+Every value comes from the environment (production: `/etc/openvibe/report.env`). See [.env.example](.env.example).
+
+| Name | Default | Meaning |
+|---|---|---|
+| `NODE_ENV` | `development` | production switches the base URL and is required for a real deploy |
+| `PORT` | `5080` | the loopback port nginx proxies to |
+| `HOST` | `127.0.0.1` | listen address |
+| `BASE_URL` | `https://openvibe.report` in production, otherwise `http://localhost:5080` | canonical links, sitemap and llms.txt |
+| `TRUST_PROXY` | `2` | Express trust proxy (the app sets no cookies, but the per-address limit uses the client address) |
+| `OV_NETWORK_INTERNAL_URL` | `http://127.0.0.1:4000` | OpenVibe.Network's loopback origin (its manifest's `internalOrigin`) |
+| `OV_LIVE_INTERNAL_URL` | `http://127.0.0.1:3000` | OpenVibe.Live's loopback origin (its manifest's `internalOrigin`) |
+| `REPORT_API_RATE_LIMIT_PER_MIN` | `120` | per-address API requests a minute |
+| `REPORT_READY_TIMEOUT_MS` | `2500` | how long a readiness probe may take |
+| `RELEASE_COMMIT` | unset | `/release.json`'s release; otherwise read from git in the checkout |
+
+## Run and test
+
+```sh
+npm install
+npm start # http://127.0.0.1:5080
+npm test # every test/*.test.js, in its own process, with both sources stubbed
+```
+
+The tests need no database, no network and no running OpenVibe site: `test/helpers/boot.js` supplies a URL-aware
+stub for both sources. They cover the clients' cache hit, expiry, failure and parameter rules; every page route and
+its 404s; the API shapes, CORS, cache and rate limit; robots/sitemap/llms; and health/readiness/static caching.
+
+`node --check` is run over `server/`, `test/` and `public/` by CI (`.github/workflows/ci.yml`, the shared
+OpenVibe.Shared test workflow, no database service).
+
+## Deploy
+
+The reference files are in `deploy/`:
+
+- `deploy/systemd/openvibe-report.service` — `User=ubuntu`, `WorkingDirectory=/opt/openvibe.report`,
+ `EnvironmentFile=/etc/openvibe/report.env`, `PORT=5080`, `SIGTERM` with a 10 s drain.
+- `deploy/nginx/openvibe.report.conf` — HTTP → HTTPS, `www` → apex 301, Let's Encrypt certificates under
+ `/etc/letsencrypt/live/openvibe.report/`, proxy to `127.0.0.1:5080`, the same security headers as Help's vhost,
+ and `/api/` GET-only with a coarse rate limit.
+
+`/api/ready` reports `network` and `live` as optional checks: one or both down is `status: "degraded"` with HTTP 200,
+so a stateless front is never marked not-ready just because a source is unavailable.
+
+## Security
+
+Every source text is escaped before it reaches HTML. Path parameters are checked against fixed patterns before any
+request. Report holds no accounts, no cookies, no database and no write routes. See [SECURITY.md](SECURITY.md).
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+# 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 (openvibe.live, openvibe.network, 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.
+
+For OpenVibe.Report specifically: the service reads two internal APIs and has no database, no accounts and no
+writes. The interesting surface is the path parameters it forwards (validated before a request), the HTML it renders
+(every source text is escaped by `server/views/index.js`) and the two source origins it is configured with.
+
+## 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,51 @@
@@ -0,0 +1,51 @@
+{
+ "repository": "OpenVibers/OpenVibe.Report",
+ "version": "0.1.0",
+ "stage": "skeleton",
+ "code": true,
+ "wave": "W21",
+ "plan": "OpenVibe.Report: a stateless front for the network's open numbers, The Rundown and every stream's after-show report",
+ "runtime": "node 22, express 4, no database, no sessions, no events",
+ "port": "5080",
+ "domain": "openvibe.report",
+ "contracts": "openvibe-contracts v0.225.0",
+ "sdk": "openvibe-sdk v0.70.0",
+ "shared": "openvibe-shared v4.29.1",
+ "packages": [
+ "openvibe-contracts v0.225.0",
+ "openvibe-sdk v0.70.0",
+ "openvibe-shared v4.29.1"
+ ],
+ "summary": "The Report room of the OpenVibe network. A stateless front with no database, no sign-in and no events: it reads OpenVibe.Network's open stats (GET /api/v1/stats?days=7|30|90) and OpenVibe.Live's Rundown (GET /api/rundown/, /topics, /t/:slug, /e/:streamId, /s/:username) and after-show reports (GET /api/recap/:streamId, /api/recap/channel/:username) over loopback, caches each answer for 2 to 10 minutes in this process, and shows it as the source answered it. Network and Live failures answer null and a page or API 404, never a made-up zero. The pages are plain placeholders the main session will design; the routes, the JSON API, the crawl artifacts, readiness (degraded, never 503, when a source is down) and the deploy files are complete.",
+ "works": [
+ "The three sources read through two clients with injectable fetch, 8 s timeouts and an in-memory cache (stats 5 min, Rundown home 2 min, topics 5 min, a topic 5 min, an episode 5 min, an after-show report 10 min, a channel's reports 5 min; 1000 entries, oldest out)",
+ "Path parameters validated before a request: slug ^[a-z0-9-]{1,80}$, a positive integer stream id, username ^[A-Za-z0-9_]{1,32}$",
+ "Pages: /, /stats?days=7|30|90, /rundown, /rundown/t/:slug, /rundown/e/:streamId, /rundown/s/:username, /after-show/:streamId, /c/:username; a bad parameter or a missing source answer is the 404 view",
+ "JSON API: /api/v1/stats, /rundown, /rundown/topics, /rundown/t/:slug, /after-show/:streamId, /c/:username — the source's answer, CORS *, public for two minutes, 120 requests a minute per address, problem+json 404s",
+ "Plumbing: helmet with Help's CSP, /api/health, /api/ready (Network and Live optional → degraded, never 503), /release.json, /release-metrics, /metrics on loopback, /shared/*, /css and /js (immutable only with the matching ?v=), robots.txt, sitemap.xml, llms.txt, graceful stop",
+ "Deploy files: systemd unit openvibe-report on 127.0.0.1:5080 and an nginx vhost for openvibe.report + www (apex redirect, certs, proxy, GET-only API)",
+ "Tests: the clients' cache, expiry, failure and validation; every page 200 and its 404s; the API shapes, CORS and cache; robots/sitemap/llms; health, readiness and static cache"
+ ],
+ "notYet": [
+ "The real page designs: v1 ships plain placeholder views, as the task asked; the main session designs the room itself",
+ "A report manifest in openvibe-contracts: this skeleton does not register one, and CI skips the contracts-service check until it lands",
+ "Any write path: Report has no accounts, no reactions and no saved views; it is a read-only front"
+ ],
+ "capabilitiesRegistered": false,
+ "serviceManifestRegistered": false,
+ "deployed": false,
+ "placeholderInSites": false,
+ "features": [
+ "open network stats",
+ "The Rundown",
+ "after-show reports",
+ "source clients with caches",
+ "server-rendered placeholder pages",
+ "JSON API",
+ "crawl artifacts",
+ "readiness",
+ "deploy files"
+ ],
+ "deployment": "planned: openvibe-ovh, unit openvibe-report.service on 127.0.0.1:5080 (git checkout /opt/openvibe.report, env /etc/openvibe/report.env), public at https://openvibe.report",
+ "updated": "2026-10-28"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+## ═══════════════════════════════════════════════════════════════
+## openvibe.report — Nginx configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/openvibe.report.conf (ov-vhost-install) — keep them in sync and reload
+## nginx after changing it. The Node app (port 5080, systemd unit openvibe-report) renders every page and serves
+## the API; nginx terminates TLS, redirects www → apex, rate-limits the API, and compresses text.
+##
+## No proxy_cache: the app decides cacheability per response (public for two minutes on pages and API, an hour on
+## the crawl artifacts, immutable only for a content-addressed asset). The API is GET-only.
+## ═══════════════════════════════════════════════════════════════
+
+limit_req_zone $binary_remote_addr zone=report_api:10m rate=5r/s;
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.report www.openvibe.report;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.report$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.openvibe.report;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.report/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.report/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ return 301 https://openvibe.report$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.report;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.report/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.report/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/openvibe.report.access.log;
+ error_log /var/log/nginx/openvibe.report.error.log;
+
+ client_max_body_size 64k;
+
+ 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;
+
+ # ── API: GET and HEAD only, with the app's 120/min limit backed by a coarse nginx one ──
+ location /api/ {
+ add_header Allow "GET, HEAD" always;
+ if ($request_method !~ ^(GET|HEAD)$) { return 405; }
+ limit_req zone=report_api burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5080;
+ }
+
+ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5080/metrics) ──
+ location ~* ^/metrics(/|$) { return 404; }
+
+ # ── Internal delivery would be loopback-only; Report has none, so refuse it from outside ──
+ location ~* ^/internal(/|$) { return 404; }
+
+ # ── Pages, crawl artifacts, static assets, release metadata ──
+ location / {
+ proxy_pass http://127.0.0.1:5080;
+ }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+[Unit]
+Description=OpenVibe.Report — Every number on OpenVibe, honest.
+# Network and Live should be up first, but Report degrades honestly without either: the pages and the API answer
+# what they can and /api/ready says degraded (never 503), because neither source is required to serve a page.
+After=network.target openvibe-network.service openvibe-live.service
+Wants=openvibe-network.service openvibe-live.service
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/openvibe.report
+EnvironmentFile=/etc/openvibe/report.env
+Environment=NODE_ENV=production
+Environment=PORT=5080
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.report/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
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,1129 @@
@@ -0,0 +1,1129 @@
+{
+ "name": "openvibe-report",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-report",
+ "version": "0.1.0",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "helmet": "^8.1.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.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.29.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "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/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-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "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/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/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.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.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.225.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.0",
+ "integrity": "sha512-msLaRYa2Byyabnf/+L3aRPwkx8MI3zWbofFhwupq+RyBCdgbA4gLlkPsJu9nK/1UTCWdNdSTfWXW5pZY1/1zbQ==",
+ "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.29.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.29.1",
+ "integrity": "sha512-wQVzLibhXmPJTJ3El5q8G6cAuykeP9P07sPPZnXsyiOc4OdA8/l4tKaQT/f97Dzjsc9WFUNzRAhnbl0W6nLixg==",
+ "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/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/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/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"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+{
+ "name": "openvibe-report",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe.Report — Every number on OpenVibe, honest.",
+ "main": "server/index.js",
+ "scripts": {
+ "start": "node server/index.js",
+ "dev": "NODE_ENV=development node server/index.js",
+ "test": "node test/run.js"
+ },
+ "keywords": [
+ "openvibe"
+ ],
+ "author": "OpenVibers",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "helmet": "^8.1.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.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.29.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,133 @@
@@ -0,0 +1,133 @@
+/* OpenVibe.Report — the room's own pieces. Colours come from the theme (--bg-*, --text-*, --border) and the room's
+ accent (.ov-room in openvibe-shared room.css: --room-accent, --room-strong, --room-fill, --room-on), so every theme,
+ light or dark, wears it. Phone first: tables become stacked rows under 700 px; nothing scrolls sideways. */
+*, *::before, *::after { box-sizing: border-box; }
+html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font-family: '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; }
+.rp-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }
+.rp a:not([class]) { color: var(--room-strong); }
+.rp h2 { margin: 0 0 12px; font-size: 1.2rem; font-weight: 800; letter-spacing: -.01em; }
+.rp section { margin-top: clamp(28px, 4vw, 44px); }
+.rp-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
+.rp-head-row h2 { margin-bottom: 8px; }
+.rp-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; white-space: nowrap; }
+:where(.rp) .ovk-i { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
+.rp-more .ovk-i { width: 1em; height: 1em; }
+.rp-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }
+.rp-kicker a { text-decoration: none; }
+.rp-page-h { padding-top: clamp(20px, 4vw, 40px); }
+.rp-page-h h1 { margin: 0; font-size: clamp(1.8rem, 4.6vw, 2.8rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.08; text-wrap: balance; overflow-wrap: anywhere; }
+.rp-lede { margin: 10px 0 0; max-width: 760px; color: var(--text-secondary, #96a7c2); font-size: 1.05rem; line-height: 1.55; }
+.rp-summary { margin: 14px 0 0; max-width: 780px; line-height: 1.65; }
+.rp-fine { margin: 0 0 14px; max-width: 800px; color: var(--text-muted, #7386a3); font-size: .88rem; line-height: 1.55; }
+.rp-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin: 10px 0 0; color: var(--text-muted, #7386a3); font-size: .86rem; }
+.rp-meta a { color: var(--text-secondary, #96a7c2); }
+.rp-meta .ovk-mark { vertical-align: middle; }
+.rp-ai { display: inline-flex; align-items: center; padding: 1px 8px; border-radius: 999px; font-size: .72rem; font-weight: 800; letter-spacing: .02em; color: var(--room-strong); background: color-mix(in srgb, var(--room-accent) 13%, transparent); }
+.rp-pill { display: inline-flex; align-items: center; gap: 2px; padding: 1px 7px; border-radius: 999px; font-size: .72rem; font-weight: 800; white-space: nowrap; }
+.rp-pill.is-up { color: var(--success-text, #22c55e); background: color-mix(in srgb, var(--success, #22c55e) 14%, transparent); }
+.rp-pill.is-down { color: var(--danger-text, #f87171); background: color-mix(in srgb, var(--danger, #f87171) 14%, transparent); }
+.rp-pill.is-flat { color: var(--text-muted, #7386a3); background: color-mix(in srgb, var(--text-muted, #7386a3) 14%, transparent); }
+.rp-tabs { display: inline-flex; gap: 4px; margin-top: 16px; padding: 4px; border-radius: 999px; background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); }
+.rp-tabs a { padding: 7px 14px; border-radius: 999px; color: var(--text-secondary, #96a7c2); text-decoration: none; font-size: .86rem; font-weight: 700; }
+.rp-tabs a[aria-current] { background: var(--room-fill); color: var(--room-on); }
+.rp-counts { display: flex; flex-wrap: wrap; gap: 6px 20px; margin: 14px 0 0; color: var(--text-secondary, #96a7c2); }
+.rp-counts b { color: var(--text-primary, #e6edf7); font-size: 1.15rem; font-variant-numeric: tabular-nums; }
+
+/* Tiles: one number each, with its window. */
+.rp-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(170px, 44%), 1fr)); gap: 12px; margin-top: 22px; }
+.rp-tile { position: relative; display: flex; flex-direction: column; gap: 4px; min-height: 104px; padding: 14px 16px; border-radius: 16px; background: linear-gradient(160deg, color-mix(in srgb, var(--room-accent) 10%, transparent), transparent 70%), var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; overflow: hidden; }
+.rp-tile-l { font-size: .74rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.rp-tile-v { font-size: 1.8rem; font-weight: 850; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
+.rp-tile-v small { font-size: .9rem; color: var(--text-muted, #7386a3); font-weight: 700; }
+.rp-tile-s { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: .82rem; color: var(--text-secondary, #96a7c2); position: relative; z-index: 1; }
+.rp-tile .rp-spark { position: absolute; right: 10px; bottom: 10px; width: 46%; height: 34px; opacity: .9; }
+.rp-spark-line { fill: none; stroke: var(--room-accent); stroke-width: 2.2; vector-effect: non-scaling-stroke; stroke-linejoin: round; }
+.rp-spark-area { fill: var(--room-accent); opacity: .14; }
+.rp-minis { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; }
+.rp-minis > div { display: flex; flex-direction: column; gap: 2px; padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.rp-minis b { font-size: 1.25rem; font-variant-numeric: tabular-nums; }
+.rp-minis span { font-size: .8rem; color: var(--text-muted, #7386a3); }
+
+/* Charts. */
+.rp-bars, .rp-curve { margin: 0; padding: 14px; border-radius: 16px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.rp-bars svg { display: block; width: 100%; height: 140px; }
+.rp-bars rect { fill: var(--room-accent); opacity: .85; }
+.rp-bars rect:hover { opacity: 1; }
+.rp-bars figcaption, .rp-curve figcaption { display: flex; justify-content: space-between; gap: 10px; margin-top: 8px; font-size: .78rem; color: var(--text-muted, #7386a3); }
+.rp-curve svg { display: block; width: 100%; height: 160px; }
+.rp-curve-line { fill: none; stroke: var(--room-accent); stroke-width: 2.4; vector-effect: non-scaling-stroke; stroke-linejoin: round; }
+.rp-curve-area { fill: var(--room-accent); opacity: .12; }
+.rp-curve-chat rect { fill: var(--text-muted, #7386a3); opacity: .45; }
+.rp-legend { display: inline-flex; align-items: center; gap: 6px; }
+.rp-legend i { display: inline-block; width: 12px; height: 4px; border-radius: 2px; background: var(--room-accent); }
+.rp-legend i.is-c { background: var(--text-muted, #7386a3); opacity: .6; }
+
+/* The stats table: rows on wide screens, stacked on phones. */
+.rp-table { display: grid; border: 1px solid var(--border, #1f2d47); border-radius: 16px; overflow: hidden; background: var(--bg-card, #0f1729); }
+.rp-tr { display: grid; grid-template-columns: minmax(0, 1.4fr) repeat(4, minmax(0, 1fr)); gap: 12px; align-items: center; padding: 11px 16px; border-top: 1px solid var(--border, #1f2d47); font-variant-numeric: tabular-nums; }
+.rp-tr:first-child { border-top: 0; }
+.rp-th { font-size: .72rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #7386a3); background: color-mix(in srgb, var(--room-accent) 5%, transparent); }
+.rp-site { display: flex; flex-direction: column; min-width: 0; }
+.rp-site a { font-weight: 700; text-decoration: none; }
+.rp-site small { color: var(--text-muted, #7386a3); font-size: .78rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+@media (max-width: 699px) {
+ .rp-th { display: none; }
+ .rp-tr { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 14px; }
+ .rp-site { grid-column: 1 / -1; }
+ .rp-tr > [data-l]::before { content: attr(data-l); display: block; font-size: .7rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+}
+
+/* The Rundown. */
+.rp-lead { padding: clamp(18px, 3vw, 28px); border-radius: 20px; border: 1px solid color-mix(in srgb, var(--room-accent) 35%, var(--border, #1f2d47)); background: radial-gradient(120% 90% at 90% 0%, color-mix(in srgb, var(--room-accent) 14%, transparent), transparent 60%), var(--bg-card, #0f1729); }
+.rp-lead h2 { font-size: clamp(1.3rem, 2.8vw, 1.8rem); line-height: 1.2; text-wrap: balance; }
+.rp-lead h2 a { color: inherit; text-decoration: none; }
+.rp-lead h2 a:hover { text-decoration: underline; }
+.rp-lead-what { margin: 0; max-width: 820px; line-height: 1.6; color: var(--text-secondary, #96a7c2); }
+.rp-lead-take { margin: 12px 0 0; max-width: 820px; padding-left: 14px; border-left: 3px solid var(--room-accent); line-height: 1.55; font-style: italic; }
+.rp-topics { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr)); gap: 12px; }
+.rp-topic { display: flex; flex-direction: column; gap: 6px; height: 100%; padding: 14px 16px; border-radius: 16px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); color: var(--text-primary, #e6edf7) !important; text-decoration: none; transition: border-color .18s, transform .18s; }
+.rp-topic:hover, .rp-topic:focus-visible { border-color: var(--room-accent); transform: translateY(-2px); outline: none; }
+.rp-topic b { font-size: 1.02rem; line-height: 1.3; }
+.rp-topic-h { color: var(--text-secondary, #96a7c2); font-size: .9rem; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
+.rp-topic .rp-meta { margin-top: auto; }
+.rp-kind { display: inline-flex; width: fit-content; padding: 1px 8px; border-radius: 999px; font-size: .7rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; color: var(--room-strong); background: color-mix(in srgb, var(--room-accent) 12%, transparent); text-decoration: none; }
+.rp-takes { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
+.rp-take { padding: 12px 16px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.rp-take h3 { margin: 0; font-size: 1rem; line-height: 1.35; }
+.rp-take h3 a { color: inherit; text-decoration: none; }
+.rp-take h3 a:hover { text-decoration: underline; }
+.rp-take p { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; font-size: .92rem; }
+.rp-take .rp-meta { margin-top: 6px; font-size: .8rem; }
+.rp-timeline { position: relative; padding-left: 18px; }
+.rp-timeline::before { content: ""; position: absolute; left: 4px; top: 6px; bottom: 6px; width: 2px; background: color-mix(in srgb, var(--room-accent) 35%, transparent); border-radius: 2px; }
+.rp-voices { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); gap: 10px; }
+.rp-voices a { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); color: var(--text-primary, #e6edf7) !important; text-decoration: none; }
+.rp-voices a:hover { border-color: var(--room-accent); }
+.rp-voices span { display: flex; flex-direction: column; min-width: 0; }
+.rp-voices small { color: var(--text-muted, #7386a3); font-size: .8rem; }
+.rp-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
+.rp-chips a, .rp-chips span { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); color: var(--text-primary, #e6edf7); text-decoration: none; font-weight: 600; font-size: .9rem; }
+.rp-chips a:hover { border-color: var(--room-accent); }
+.rp-person { display: flex; align-items: center; gap: 16px; }
+
+/* The after-show report. */
+.rp-moments { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
+.rp-moments li { display: flex; align-items: flex-start; gap: 12px; padding: 10px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.rp-moments li > span { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
+.rp-at { flex: none; display: inline-flex; align-items: center; gap: 4px; min-height: 32px; padding: 0 10px; border-radius: 999px; background: var(--room-fill); color: var(--room-on) !important; font-size: .78rem; font-weight: 800; text-decoration: none; font-variant-numeric: tabular-nums; }
+.rp-at .ovk-i { width: .9em; height: .9em; fill: currentColor; stroke: none; }
+.rp-act { display: flex; flex-wrap: wrap; gap: 8px; margin: 28px 0 0; }
+.rp-btn { display: inline-flex; align-items: center; min-height: 42px; padding: 0 18px; border-radius: 999px; background: var(--room-fill); color: var(--room-on) !important; font-weight: 700; font-size: .92rem; text-decoration: none; }
+.rp-btn.is-quiet { background: transparent; color: var(--room-strong) !important; border: 1px solid color-mix(in srgb, var(--room-accent) 45%, var(--border, #1f2d47)); }
+.rp-grid { list-style: none; margin: 22px 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); gap: 18px; }
+.rp-scene-num { display: grid; gap: 6px; }
+.rp-scene-num .rp-pill { justify-self: start; }
+.rp-scene-num b { font-size: 2.4rem; font-weight: 850; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
+.rp-scene-num .rp-spark { width: 100%; height: 90px; }
+.rp-eps { display: grid; gap: 10px; margin: 0; }
+.rp-eps > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.rp-eps dt code { font: 600 .92rem/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--room-strong); overflow-wrap: anywhere; }
+.rp-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
+@media (prefers-reduced-motion: reduce) { .rp-topic { transition: none; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
+'use strict';
+/* OpenVibe.Report — placeholder script. The real pages add their own in-browser conveniences. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -0,0 +1,147 @@
+'use strict';
+
+/**
+ * OpenVibe.Report — Express app factory. server/index.js listens; tests build their own instance with a stub
+ * fetch so no test needs the network or a running site.
+ *
+ * /, /stats, /rundown, /rundown/t/:slug, /rundown/e/:streamId, /rundown/s/:username,
+ * /after-show/:streamId, /c/:username the pages (http/pages.js)
+ * /api/v1/* the JSON API (http/api.js)
+ * /robots.txt, /sitemap.xml, /llms.txt crawl artifacts (http/discovery.js)
+ * /api/health, /api/ready, /release.json, /release-metrics the plumbing (loopback scrape for /metrics)
+ *
+ * The service is stateless: no database, no sessions and no events. The two source clients are the only state
+ * (their in-process caches). The plumbing is the skeleton's, modelled on OpenVibe.Help.
+ */
+const path = require('path');
+const express = require('express');
+const helmet = require('helmet');
+const rateLimit = require('express-rate-limit');
+const contracts = require('openvibe-contracts');
+const cache = require('openvibe-shared/cache-policy');
+
+const configLib = require('./config');
+const { createNetworkClient } = require('./data/network');
+const { createLiveClient } = require('./data/live');
+const { createApi } = require('./http/api');
+const { createPageRoutes } = require('./http/pages');
+const { createDiscoveryRoutes } = require('./http/discovery');
+const { createServiceReadiness } = require('./observability');
+const views = require('./views');
+const { PUBLIC_DIR, assetVersion } = require('./assets');
+
+const VERSION = require('../package.json').version;
+const PAGE_CACHE = 'public, max-age=120';
+
+/** opts: config, fetchImpl, log, now, network, live, releaseEnv */
+async function createApp(opts = {}) {
+ const config = opts.config || configLib.load();
+ const log = opts.log || console;
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
+ const now = opts.now || (() => Date.now());
+
+ const network = opts.network || createNetworkClient({ config, fetchImpl, log, now });
+ const live = opts.live || createLiveClient({ config, fetchImpl, log, now });
+ views.configure({ baseUrl: config.baseUrl });
+
+ const ctx = { config, log, fetchImpl, now, network, live };
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', config.trustProxy);
+
+ // Request context (traceparent + X-OpenVibe-Request-Id, req.ov) for every response, including problem+json.
+ app.use(contracts.http.middleware());
+
+ // The OpenVibe Frame (theme-loader, navbar, footer) comes from this site's /shared copy; the inline boot is ours.
+ 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'],
+ 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' },
+ }));
+
+ // ── Release metadata and Prometheus metrics (the metrics route stays host-local in nginx) ──
+ const release = require('openvibe-shared/release').createRelease({
+ service: 'report',
+ root: path.join(__dirname, '..'),
+ env: opts.releaseEnv || process.env,
+ });
+ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'report', release: release.release });
+ app.locals.metrics = metrics.registry;
+ app.locals.ctx = ctx;
+ ctx.release = release;
+ ctx.metrics = metrics.registry;
+ ctx.metricsStop = metrics.stop;
+
+ // ── Machine endpoints ───────────────────────────────────
+ app.get('/api/health', (_req, res) => res.set('Cache-Control', 'no-store').json({ status: 'ok', service: 'openvibe-report', version: VERSION }));
+ release.mount(app, { registry: metrics.registry });
+ const readiness = createServiceReadiness({ config, fetchImpl, release: release.release });
+ ctx.readiness = readiness;
+ app.get('/api/ready', readiness.handler);
+
+ // ── Static assets: this site's own (content-hashed ?v= → immutable) and the Shared Frame ──
+ require('./views').setRelease(release && release.release);
+ 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: CORS-open, public for two minutes, 120 requests a minute per address ──
+ app.use('/api/v1', (_req, res, next) => {
+ res.set('Access-Control-Allow-Origin', '*');
+ res.set('Cache-Control', PAGE_CACHE);
+ next();
+ });
+ app.use('/api/v1', rateLimit({
+ windowMs: 60_000,
+ limit: config.apiRateLimitPerMinute,
+ 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({ network, live }));
+ app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
+
+ // ── Crawl artifacts, then the pages ─────────────────────
+ app.use(createDiscoveryRoutes(ctx));
+ app.use(createPageRoutes(ctx));
+ app.use((_req, res) => {
+ res.status(404).set('Cache-Control', PAGE_CACHE).type('html').send(views.notFound());
+ });
+
+ // eslint-disable-next-line no-unused-vars
+ app.use((err, req, res, _next) => {
+ log.error('[OpenVibe.Report]', err && err.message ? String(err.message).slice(0, 300) : err);
+ if (res.headersSent) return;
+ if (req.path.startsWith('/api/')) {
+ return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
+ }
+ return res.status(500).set('Cache-Control', 'no-store').type('text/plain').send('Something went wrong on our side. Try again in a moment.');
+ });
+
+ return { app, ctx };
+}
+
+module.exports = { createApp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+'use strict';
+
+/**
+ * Content-addressed URLs for this site's own assets: /css/app.css?v=<first 10 hex of SHA-256>. The static
+ * middleware treats a matching ?v= as immutable (openvibe-shared/cache-policy); any other URL gets the short
+ * public window, because those bytes may change under the same URL.
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+
+const PUBLIC_DIR = path.join(__dirname, '..', 'public');
+const hashes = new Map();
+
+function assetVersion(rel) {
+ const key = String(rel || '').replace(/^\/+/, '');
+ if (hashes.has(key)) return hashes.get(key);
+ let version = 'dev';
+ try {
+ version = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, key))).digest('hex').slice(0, 10);
+ } catch {
+ /* a missing asset stays at a stable placeholder */
+ }
+ hashes.set(key, version);
+ return version;
+}
+
+const asset = (rel) => `/${String(rel || '').replace(/^\/+/, '')}?v=${assetVersion(rel)}`;
+
+module.exports = { PUBLIC_DIR, assetVersion, asset };
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+'use strict';
+
+/**
+ * OpenVibe.Report configuration. Every value comes from the environment (production: /etc/openvibe/report.env,
+ * see .env.example). load(env) is pure so tests can build a config without touching process.env.
+ *
+ * Report is stateless: it has no database, no sessions and no events. It reads two loopback APIs:
+ * OpenVibe.Network's open stats and OpenVibe.Live's Rundown and after-show reports.
+ */
+require('dotenv').config();
+
+const trim = (s) => String(s || '').replace(/\/+$/, '');
+const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
+
+function load(env = process.env) {
+ const nodeEnv = env.NODE_ENV || 'development';
+ const isProduction = nodeEnv === 'production';
+ const port = int(env.PORT, 5080);
+ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.report' : `http://localhost:${port}`));
+
+ return {
+ service: 'report',
+ name: 'OpenVibe.Report',
+ port,
+ host: env.HOST || '127.0.0.1',
+ nodeEnv,
+ isProduction,
+ baseUrl,
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
+
+ // The two sources. Both serve these read APIs on loopback; manifests/services/{network,live}.json in
+ // openvibe-contracts name the same defaults.
+ networkUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
+ liveUrl: trim(env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),
+
+ // One caller may make this many API requests a minute (express-rate-limit, keyed by address).
+ apiRateLimitPerMinute: Math.max(1, int(env.REPORT_API_RATE_LIMIT_PER_MIN, 120)),
+
+ // How long a readiness probe may take before the source is reported as not answering.
+ readyTimeoutMs: Math.max(200, int(env.REPORT_READY_TIMEOUT_MS, 2500)),
+ };
+}
+
+module.exports = { load };
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+
+/**
+ * A small in-memory cache for the two source clients: a Map with an expiry per entry, 1000 entries at most and
+ * the oldest inserted entry dropped first. Read APIs are public and change slowly, so a page view is usually a
+ * cache hit; a process restart is the only thing that empties it.
+ *
+ * const cache = createCache({ max: 1000, now: () => Date.now() });
+ * cache.get(key) undefined when absent or expired
+ * cache.set(key, value, ttlMs)
+ */
+function createCache({ max = 1000, now = () => Date.now() } = {}) {
+ const entries = new Map();
+ const clock = typeof now === 'function' ? now : () => Date.now();
+
+ function get(key) {
+ const hit = entries.get(key);
+ if (!hit) return undefined;
+ if (hit.expiresAt <= clock()) {
+ entries.delete(key);
+ return undefined;
+ }
+ // Do not move it to the end: "oldest out" means insertion order, not least-recently-used.
+ return hit.value;
+ }
+
+ function set(key, value, ttlMs) {
+ entries.delete(key);
+ entries.set(key, { value, expiresAt: clock() + Math.max(0, Number(ttlMs) || 0) });
+ while (entries.size > Math.max(1, Number(max) || 1)) {
+ const oldest = entries.keys().next().value;
+ entries.delete(oldest);
+ }
+ return value;
+ }
+
+ return {
+ get,
+ set,
+ delete: (key) => entries.delete(key),
+ get size() { return entries.size; },
+ };
+}
+
+module.exports = { createCache };
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+
+/**
+ * OpenVibe.Live's read APIs:
+ * GET /api/rundown/ the Rundown's home: on air, the lead, streams, trending, voices, counts
+ * GET /api/rundown/topics every topic
+ * GET /api/rundown/t/:slug one topic and its takes
+ * GET /api/rundown/e/:id one stream as an episode
+ * GET /api/rundown/s/:user a streamer's episodes, topics and lines
+ * GET /api/recap/:id an after-show report
+ * GET /api/recap/channel/:user a channel's latest reports
+ *
+ * Answers are held in this process (2 to 10 minutes, as each source changes); a failure answers null after
+ * one short log line. Path parameters are checked here, before a request is made: a slug that cannot exist is
+ * a 404 without asking Live.
+ */
+const { createCache } = require('./cache');
+const { trim, requestJson, isSlug, streamIdOf, isUsername, TIMEOUT_MS } = require('./source');
+
+const TTL = {
+ rundown: 2 * 60_000,
+ topics: 5 * 60_000,
+ topic: 5 * 60_000,
+ episode: 5 * 60_000,
+ speaker: 5 * 60_000,
+ recap: 10 * 60_000,
+ channelRecaps: 5 * 60_000,
+};
+
+/**
+ * opts: config ({ liveUrl }), fetchImpl, log, now, timeoutMs, cacheMax
+ */
+function createLiveClient({ config = {}, fetchImpl = globalThis.fetch, log = console, now = () => Date.now(), timeoutMs = TIMEOUT_MS, cacheMax = 1000 } = {}) {
+ const baseUrl = trim(config.liveUrl || config.liveInternalUrl || 'http://127.0.0.1:3000');
+ const cache = createCache({ max: cacheMax, now });
+
+ async function cached(key, path, ttl, label) {
+ const hit = cache.get(key);
+ if (hit !== undefined) return hit;
+ const data = await requestJson(`${baseUrl}${path}`, { fetchImpl, log, timeoutMs, label });
+ if (data === null) return null;
+ return cache.set(key, data, ttl);
+ }
+
+ function rundown() {
+ return cached('rundown:home', '/api/rundown/', TTL.rundown, 'live rundown');
+ }
+
+ function topics() {
+ return cached('rundown:topics', '/api/rundown/topics', TTL.topics, 'live topics');
+ }
+
+ function topic(slug) {
+ if (!isSlug(slug)) return null;
+ return cached(`rundown:topic:${slug}`, `/api/rundown/t/${encodeURIComponent(slug)}`, TTL.topic, 'live topic');
+ }
+
+ /** Live's episode route is named by the stream id (a positive integer). */
+ function episode(streamId) {
+ const id = streamIdOf(streamId);
+ if (!id) return null;
+ return cached(`rundown:episode:${id}`, `/api/rundown/e/${id}`, TTL.episode, 'live episode');
+ }
+
+ function speaker(username) {
+ if (!isUsername(username)) return null;
+ const key = username.toLowerCase();
+ return cached(`rundown:speaker:${key}`, `/api/rundown/s/${encodeURIComponent(username)}`, TTL.speaker, 'live speaker');
+ }
+
+ function recap(streamId) {
+ const id = streamIdOf(streamId);
+ if (!id) return null;
+ return cached(`recap:${id}`, `/api/recap/${id}`, TTL.recap, 'live recap');
+ }
+
+ function channelRecaps(username) {
+ if (!isUsername(username)) return null;
+ const key = username.toLowerCase();
+ return cached(`recaps:channel:${key}`, `/api/recap/channel/${encodeURIComponent(username)}`, TTL.channelRecaps, 'live channel recaps');
+ }
+
+ return { rundown, topics, topic, episode, speaker, recap, channelRecaps, cache, baseUrl, ttlMs: TTL };
+}
+
+module.exports = { createLiveClient, TTL };
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+
+/**
+ * OpenVibe.Network's open stats: GET /api/v1/stats?days=7|30|90.
+ *
+ * The answer is public counts only. It is held for five minutes in this process and a failure answers null
+ * (after one short log line), never an exception: a page renders "not available", never a made-up zero.
+ */
+const { createCache } = require('./cache');
+const { trim, requestJson, normaliseDays, TIMEOUT_MS } = require('./source');
+
+const STATS_TTL_MS = 5 * 60_000;
+
+/**
+ * opts: config ({ networkUrl }), fetchImpl, log, now, timeoutMs, cacheMax
+ */
+function createNetworkClient({ config = {}, fetchImpl = globalThis.fetch, log = console, now = () => Date.now(), timeoutMs = TIMEOUT_MS, cacheMax = 1000 } = {}) {
+ const baseUrl = trim(config.networkUrl || config.networkInternalUrl || 'http://127.0.0.1:4000');
+ const cache = createCache({ max: cacheMax, now });
+
+ /** The numbers for the last 7, 30 or 90 days; 30 for anything else. */
+ async function stats(days) {
+ const window = normaliseDays(days);
+ const hit = cache.get(window);
+ if (hit !== undefined) return hit;
+ const data = await requestJson(`${baseUrl}/api/v1/stats?days=${window}`, {
+ fetchImpl, log, timeoutMs, label: `network stats (${window}d)`,
+ });
+ if (data === null) return null;
+ return cache.set(window, data, STATS_TTL_MS);
+ }
+
+ return { stats, cache, baseUrl, ttlMs: STATS_TTL_MS };
+}
+
+module.exports = { createNetworkClient, STATS_TTL_MS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+
+/**
+ * Shared pieces for the two read-only source clients (server/data/network.js and server/data/live.js):
+ * one short failure log line, the 8 s timeout, and the parameter rules. A source that cannot answer is
+ * never an exception the page has to catch — it is null.
+ */
+
+const TIMEOUT_MS = 8_000;
+const SLUG_RE = /^[a-z0-9-]{1,80}$/;
+const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;
+
+const trim = (s) => String(s || '').replace(/\/+$/, '');
+
+function reasonOf(err) {
+ if (!err) return 'no response';
+ if (err.name === 'TimeoutError' || err.name === 'AbortError') return 'timeout';
+ return String(err.message || err).replace(/\s+/g, ' ').slice(0, 160);
+}
+
+/** One short line, never the request body, never a stack. */
+function warn(log, message) {
+ const line = `[OpenVibe.Report] ${String(message).replace(/\s+/g, ' ').slice(0, 240)}`;
+ try {
+ const fn = (log && (log.warn || log.error)) || console.warn;
+ fn.call(log || console, line);
+ } catch {
+ /* logging must never fail the page */
+ }
+}
+
+/**
+ * GET a JSON document with an 8 s timeout. Returns the parsed body, or null after one log line.
+ * A 404 is the source answering "no such thing", so it is null without a log line.
+ */
+async function requestJson(url, { fetchImpl = globalThis.fetch, log = console, label = 'source', timeoutMs = TIMEOUT_MS } = {}) {
+ let res;
+ try {
+ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
+ ? AbortSignal.timeout(timeoutMs)
+ : undefined;
+ res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal });
+ } catch (err) {
+ warn(log, `${label} failed: ${reasonOf(err)}`);
+ return null;
+ }
+
+ if (!res || !res.ok) {
+ if (!res || res.status !== 404) warn(log, `${label} failed: HTTP ${res ? res.status : 'no response'}`);
+ return null;
+ }
+
+ try {
+ return await res.json();
+ } catch (err) {
+ warn(log, `${label} failed: ${reasonOf(err)}`);
+ return null;
+ }
+}
+
+/** ?days=7|30|90; anything else is 30, exactly as OpenVibe.Network's own route reads it. */
+function normaliseDays(value) {
+ const raw = Array.isArray(value) ? value[0] : value;
+ const days = parseInt(raw, 10);
+ return [7, 30, 90].includes(days) ? days : 30;
+}
+
+const isSlug = (value) => typeof value === 'string' && SLUG_RE.test(value);
+
+/** A positive integer stream id. Returns the number, or null. */
+function streamIdOf(value) {
+ const raw = String(value == null ? '' : value);
+ if (!/^\d{1,18}$/.test(raw)) return null;
+ const id = Number(raw);
+ return Number.isSafeInteger(id) && id > 0 ? id : null;
+}
+
+const isStreamId = (value) => streamIdOf(value) !== null;
+const isUsername = (value) => typeof value === 'string' && USERNAME_RE.test(value);
+
+module.exports = { TIMEOUT_MS, trim, warn, requestJson, normaliseDays, isSlug, streamIdOf, isStreamId, isUsername };
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+'use strict';
+
+/**
+ * /api/v1 — the read APIs of OpenVibe.Network and OpenVibe.Live, proxied as the source answers them.
+ *
+ * GET /api/v1/stats?days=7|30|90
+ * GET /api/v1/rundown
+ * GET /api/v1/rundown/topics
+ * GET /api/v1/rundown/t/:slug
+ * GET /api/v1/after-show/:streamId
+ * GET /api/v1/c/:username
+ *
+ * A bad parameter is checked here, before the source is asked, and is a 404 problem+json, exactly like a source
+ * that answered nothing. Every /api/v1 answer is CORS-open and public for two minutes; the app mounts the 120/min
+ * per-address limit in front of this router.
+ */
+const express = require('express');
+const contracts = require('openvibe-contracts');
+const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');
+
+const CACHE = 'public, max-age=120';
+
+function createApi({ network, live }) {
+ const r = express.Router();
+
+ r.use((req, res, next) => {
+ res.set('Access-Control-Allow-Origin', '*');
+ res.set('Cache-Control', CACHE);
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
+ res.set('Allow', 'GET, HEAD');
+ return problem(req, res, 405, 'method.not_allowed', 'This API is read-only; use GET.');
+ }
+ return next();
+ });
+
+ function problem(req, res, status, code, detail) {
+ return contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
+ }
+
+ function notFound(req, res, code = 'report.not_found', detail = 'No such answer from the source.') {
+ return problem(req, res, 404, code, detail);
+ }
+
+ r.get('/stats', async (req, res) => {
+ const stats = await network.stats(normaliseDays(req.query.days));
+ if (!stats) return notFound(req, res, 'report.stats_not_found', 'OpenVibe.Network did not answer with the open numbers.');
+ return res.json(stats);
+ });
+
+ r.get('/rundown', async (req, res) => {
+ const rundown = await live.rundown();
+ if (!rundown) return notFound(req, res, 'report.rundown_not_found', 'OpenVibe.Live did not answer with The Rundown.');
+ return res.json(rundown);
+ });
+
+ r.get('/rundown/topics', async (req, res) => {
+ const topics = await live.topics();
+ if (!topics) return notFound(req, res, 'report.topics_not_found', 'OpenVibe.Live did not answer with the topics.');
+ return res.json(topics);
+ });
+
+ r.get('/rundown/t/:slug', async (req, res) => {
+ const { slug } = req.params;
+ if (!isSlug(slug)) return notFound(req, res, 'report.topic_not_found', 'No such topic.');
+ const topic = await live.topic(slug);
+ if (!topic) return notFound(req, res, 'report.topic_not_found', 'No such topic.');
+ return res.json(topic);
+ });
+
+ r.get('/after-show/:streamId', async (req, res) => {
+ if (!isStreamId(req.params.streamId)) return notFound(req, res, 'report.recap_not_found', 'No such after-show report.');
+ const recap = await live.recap(streamIdOf(req.params.streamId));
+ if (!recap) return notFound(req, res, 'report.recap_not_found', 'No such after-show report.');
+ return res.json(recap);
+ });
+
+ r.get('/c/:username', async (req, res) => {
+ const { username } = req.params;
+ if (!isUsername(username)) return notFound(req, res, 'report.channel_not_found', 'No such channel.');
+ const recaps = await live.channelRecaps(username);
+ if (!recaps) return notFound(req, res, 'report.channel_not_found', 'No such channel.');
+ return res.json(recaps);
+ });
+
+ r.use((req, res) => notFound(req, res, 'route.not_found', 'No such API route.'));
+ return r;
+}
+
+module.exports = { createApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+
+/**
+ * Crawl artifacts for openvibe.report, built with openvibe-shared/seo:
+ *
+ * GET /robots.txt the public pages are crawlable; the API is not
+ * GET /sitemap.xml /, /stats, /rundown and every /rundown/t/<slug> from the Rundown's topics
+ * GET /llms.txt a markdown map of the site and its machine-readable answers
+ *
+ * The sitemap reads topics through the same cached client as the pages, so a crawler is no heavier than a reader
+ * and a source that does not answer leaves the three static pages in place.
+ */
+const fs = require('fs');
+const path = require('path');
+const seo = require('openvibe-shared/seo');
+const express = require('express');
+
+const SITE_NAME = 'OpenVibe.Report';
+const DESCRIPTION = 'OpenVibe.Report is the Report room of the OpenVibe network: the open numbers every OpenVibe site publishes, The Rundown of what streamers talked about, and each stream\'s after-show report.';
+const CACHE = 'public, max-age=3600';
+
+function dayOf(value) {
+ const m = String(value == null ? '' : value).match(/^(\d{4}-\d{2}-\d{2})/);
+ return m ? m[1] : null;
+}
+
+function siteUpdated() {
+ try {
+ return dayOf(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated);
+ } catch {
+ return null;
+ }
+}
+
+function createDiscoveryRoutes({ config, live }) {
+ const r = express.Router();
+ const site = String(config.baseUrl || 'https://openvibe.report').replace(/\/+$/, '');
+ const abs = (p) => `${site}${p}`;
+
+ r.get('/robots.txt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', CACHE).send(
+ '# openvibe.report: the public pages are for search and AI crawlers; the JSON API is not.\n'
+ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: ['/api/'] }),
+ );
+ });
+
+ r.get('/sitemap.xml', async (_req, res) => {
+ const data = await live.topics();
+ const topics = (data && Array.isArray(data.topics)) ? data.topics : [];
+ const lastmod = siteUpdated();
+ const pages = [
+ { loc: abs('/'), changefreq: 'daily', priority: 1.0 },
+ { loc: abs('/stats'), changefreq: 'daily', priority: 0.9 },
+ { loc: abs('/rundown'), changefreq: 'daily', priority: 0.9 },
+ ...topics.filter((t) => t && typeof t.slug === 'string').map((t) => ({
+ loc: abs(`/rundown/t/${encodeURIComponent(t.slug)}`),
+ changefreq: 'weekly',
+ priority: 0.7,
+ })),
+ ].map((p) => ({ ...p, ...(lastmod ? { lastmod } : {}) }));
+ res.type('application/xml').set('Cache-Control', CACHE).send(seo.sitemapXml(pages));
+ });
+
+ r.get('/llms.txt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', CACHE).send(seo.llmsTxt({
+ name: SITE_NAME,
+ summary: 'Every number on OpenVibe, honest: the network\'s open stats, The Rundown of what streamers talked about, and every stream\'s after-show report.',
+ details: 'The numbers and the write-ups are read from OpenVibe.Network and OpenVibe.Live, cached in this process for a few minutes, and shown as the sources answer them. Nothing here is about a person.',
+ sections: [
+ { title: 'Start here', links: [
+ { title: 'OpenVibe.Report', url: abs('/'), note: 'the Report room' },
+ { title: 'Open numbers', url: abs('/stats'), note: 'page views, accounts and traffic for every OpenVibe site' },
+ { title: 'The Rundown', url: abs('/rundown'), note: 'what streamers talked about, topic by topic' },
+ ] },
+ { title: 'Machine-readable', links: [
+ { title: 'Open stats (JSON)', url: abs('/api/v1/stats?days=7'), note: '?days=7, 30 or 90' },
+ { title: 'The Rundown (JSON)', url: abs('/api/v1/rundown') },
+ { title: 'Topics (JSON)', url: abs('/api/v1/rundown/topics') },
+ { title: 'Sitemap', url: abs('/sitemap.xml') },
+ { title: 'Release metadata (JSON)', url: abs('/release.json') },
+ ] },
+ { title: 'Elsewhere', links: [
+ { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'the network and its open stats' },
+ { title: 'OpenVibe.Live', url: 'https://openvibe.live', note: 'the streams, The Rundown and the after-show reports' },
+ ] },
+ ],
+ }));
+ });
+
+ return r;
+}
+
+module.exports = { createDiscoveryRoutes, DESCRIPTION, SITE_NAME };
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+
+/**
+ * The pages, server-rendered from a plain model (server/views/index.js):
+ *
+ * GET / stats 7 days + the Rundown + topics
+ * GET /stats?days= 7 | 30 | 90 (anything else is 30)
+ * GET /rundown the Rundown + topics
+ * GET /rundown/t/:slug one topic
+ * GET /rundown/e/:streamId one stream as an episode
+ * GET /rundown/s/:username one streamer
+ * GET /after-show/:streamId one after-show report
+ * GET /c/:username one channel's after-show reports
+ *
+ * A bad path parameter is a 404 without a source request; so is a source that answers nothing. Pages are public
+ * for two minutes.
+ */
+const express = require('express');
+const views = require('../views');
+const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');
+
+const CACHE = 'public, max-age=120';
+
+function createPageRoutes({ network, live }) {
+ const r = express.Router();
+
+ function send(res, html, status = 200) {
+ res.status(status).set('Cache-Control', CACHE).type('html').send(html);
+ }
+ function notFound(res) {
+ return send(res, views.notFound(), 404);
+ }
+
+ r.get('/', async (_req, res) => {
+ const [stats, rundown, topics] = await Promise.all([
+ network.stats(7),
+ live.rundown(),
+ live.topics(),
+ ]);
+ // Each part of the home stands alone: a source that does not answer leaves its part out, not the page.
+ return send(res, views.home({ stats, rundown, topics }));
+ });
+
+ r.get('/developers', (_req, res) => send(res, views.developers()));
+
+ r.get('/stats', async (req, res) => {
+ const days = normaliseDays(req.query.days);
+ const stats = await network.stats(days);
+ if (!stats) return notFound(res);
+ return send(res, views.stats({ stats, days }));
+ });
+
+ r.get('/rundown', async (_req, res) => {
+ const [rundown, topics] = await Promise.all([live.rundown(), live.topics()]);
+ if (!rundown || !topics) return notFound(res);
+ return send(res, views.rundown({ rundown, topics }));
+ });
+
+ r.get('/rundown/t/:slug', async (req, res) => {
+ const { slug } = req.params;
+ if (!isSlug(slug)) return notFound(res);
+ const topic = await live.topic(slug);
+ if (!topic) return notFound(res);
+ return send(res, views.topic({ topic }));
+ });
+
+ r.get('/rundown/e/:streamId', async (req, res) => {
+ if (!isStreamId(req.params.streamId)) return notFound(res);
+ const episode = await live.episode(streamIdOf(req.params.streamId));
+ if (!episode) return notFound(res);
+ return send(res, views.episode({ episode }));
+ });
+
+ r.get('/rundown/s/:username', async (req, res) => {
+ const { username } = req.params;
+ if (!isUsername(username)) return notFound(res);
+ const speaker = await live.speaker(username);
+ if (!speaker) return notFound(res);
+ return send(res, views.speaker({ speaker, username }));
+ });
+
+ r.get('/after-show/:streamId', async (req, res) => {
+ if (!isStreamId(req.params.streamId)) return notFound(res);
+ const recap = await live.recap(streamIdOf(req.params.streamId));
+ if (!recap) return notFound(res);
+ return send(res, views.recap({ recap }));
+ });
+
+ r.get('/c/:username', async (req, res) => {
+ const { username } = req.params;
+ if (!isUsername(username)) return notFound(res);
+ const recaps = await live.channelRecaps(username);
+ if (!recaps) return notFound(res);
+ return send(res, views.channel({ username, recaps }));
+ });
+
+ return r;
+}
+
+module.exports = { createPageRoutes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+
+/**
+ * OpenVibe.Report — process entry. `node server/index.js`
+ * Listens on PORT (5080) behind nginx (deploy/).
+ */
+const { createApp } = require('./app');
+const { gracefulStop } = require('openvibe-sdk/service');
+
+/**
+ * The process stop (openvibe-sdk/service): the HTTP drain runs, then anything the product added closes. Report
+ * has no database, no key refresher and no subscriptions, so there is nothing else to close. Exported so a test can
+ * inject `exit` and `signals: false`.
+ */
+function createLifecycle({ server, exit, signals } = {}) {
+ return gracefulStop({
+ name: 'OpenVibe.Report',
+ server,
+ deadlineExitCode: 0,
+ exit,
+ signals,
+ deadlineMs: 10_000,
+ });
+}
+
+async function start() {
+ const { app, ctx } = await createApp();
+ const { config } = ctx;
+
+ const server = app.listen(config.port, config.host, () => {
+ console.log(`[OpenVibe.Report] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (network ${config.networkUrl}, live ${config.liveUrl})`);
+ });
+ server.keepAliveTimeout = 65_000;
+ createLifecycle({ server, ctx });
+ return { server, ctx };
+}
+
+if (require.main === module) {
+ start().catch((err) => {
+ console.error('[OpenVibe.Report] failed to start:', err);
+ process.exit(1);
+ });
+}
+
+module.exports = { start, createLifecycle };
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'use strict';
+
+/**
+ * Truthful readiness for GET /api/ready (openvibe-shared/ready, Track O).
+ *
+ * Report has no database, no sessions and no queues: its only dependencies are the two loopback read APIs.
+ * Both checks are optional, so a source that does not answer makes the service degraded, never not_ready:
+ * the process still serves the pages it can and the 503 would stop the deploy of a stateless front.
+ *
+ * network optional OpenVibe.Network answers /api/health
+ * live optional OpenVibe.Live answers /api/health
+ */
+const { createReadiness } = require('openvibe-shared/ready');
+
+function createServiceReadiness({ config, fetchImpl = globalThis.fetch, release = null }) {
+ const healthOf = (name, origin) => ({
+ name,
+ required: false,
+ description: `${name} answers its health endpoint`,
+ async check() {
+ const url = `${String(origin || '').replace(/\/+$/, '')}/api/health`;
+ try {
+ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
+ ? AbortSignal.timeout(config.readyTimeoutMs || 2500)
+ : undefined;
+ const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal });
+ if (res && res.ok) return true;
+ return `${name} answered HTTP ${res ? res.status : 'nothing'}`;
+ } catch (err) {
+ return `${name} did not answer: ${(err && err.message) || 'fetch failed'}`;
+ }
+ },
+ });
+
+ return createReadiness({
+ service: 'report',
+ release,
+ timeoutMs: config.readyTimeoutMs || 2500,
+ checks: [
+ healthOf('network', config.networkUrl),
+ healthOf('live', config.liveUrl),
+ ],
+ });
+}
+
+module.exports = { createServiceReadiness };
new file mode 100644
new file mode 100644
@@ -0,0 +1,317 @@
@@ -0,0 +1,317 @@
+'use strict';
+/**
+ * The Report room's pages (plan 2026-10-28 §5.4). Server-rendered and whole without JavaScript; the Frame (navbar,
+ * theme, footer) comes from openvibe-shared/shell, the front door from sc.room() and the accent from rooms.js.
+ * server/http/pages.js builds each model from Network's open stats and Live's Rundown and after-show reports. Every
+ * number shown is one a source gave, with its window or date; a missing one is left out, never shown as 0.
+ *
+ * AI-written text (headlines, summaries, takes) is labelled. The after-show report never prints the raw speech sample
+ * or quotes of the transcript: it shows the AI's titles and topics and links to the moment in the recording.
+ */
+const shell = require('openvibe-shared/shell');
+const ovServe = require('openvibe-shared/serve');
+const appIcon = require('openvibe-shared/app-icon');
+const kit = require('openvibe-shared/kit');
+const sc = require('openvibe-shared/showcase');
+const rooms = require('openvibe-shared/rooms');
+const { asset } = require('../assets');
+
+const NAME = 'OpenVibe.Report';
+const NETWORK = 'https://openvibe.network';
+const ROOM = rooms.get('report');
+const esc = kit.esc;
+let RELEASE = 'dev';
+let BASE = 'https://openvibe.report';
+function setRelease(id) { if (id) RELEASE = String(id); }
+function configure({ baseUrl } = {}) { if (baseUrl) BASE = String(baseUrl).replace(/\/+$/, ''); }
+const canon = (path) => `${BASE}${path}`;
+
+// ── Formatting: numbers as people read them; dates in UTC (Live stores "YYYY-MM-DD hh:mm:ss" in UTC) ──
+const n = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));
+const fmt = (v) => { const x = n(v); if (x == null) return '—'; return Math.abs(x) >= 1e6 ? `${(x / 1e6).toFixed(1).replace(/\.0$/, '')}M` : Math.abs(x) >= 1e4 ? `${Math.round(x / 1e3)}k` : x.toLocaleString('en-US'); };
+const exact = (v) => { const x = n(v); return x == null ? '—' : x.toLocaleString('en-US'); };
+const ts = (s) => { const str = String(s || ''); const t = Date.parse(/[TZ]/.test(str) || !str.includes(' ') ? str : `${str.replace(' ', 'T')}Z`); return Number.isFinite(t) ? t : null; };
+const day = (s) => { const t = ts(s); return t == null ? '' : new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }); };
+const dayTime = (s) => { const t = ts(s); return t == null ? '' : `${new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })}, ${new Date(t).toISOString().slice(11, 16)} UTC`; };
+const mins = (sec) => { const s = n(sec); if (s == null) return ''; const m = Math.round(s / 60); return m >= 60 ? `${Math.floor(m / 60)} h ${m % 60} min` : `${m} min`; };
+const pct = (v) => { const x = n(v); if (x == null) return ''; return `<span class="rp-pill ${x > 0 ? 'is-up' : x < 0 ? 'is-down' : 'is-flat'}">${x > 0 ? '▲' : x < 0 ? '▼' : '•'} ${Math.abs(x)}%</span>`; };
+const who = (u) => (u ? u.display_name || u.username || '' : '');
+const AI = '<span class="rp-ai" title="Written by AI from the stream\'s own speech, chat and numbers">Written by AI</span>';
+
+const NAV = [
+ { label: 'Numbers', href: '/stats', icon: 'fa-chart-line' },
+ { label: 'The Rundown', href: '/rundown', icon: 'fa-newspaper' },
+ { label: 'API', href: '/developers', icon: 'fa-code', more: true },
+];
+
+function layout({ title, description, canonical = null, path = null, robots = 'index, follow', body, active = '/', jsonLd = [] }) {
+ canonical = canonical || (path ? canon(path) : undefined);
+ const navbar = {
+ service: 'report',
+ apiBase: NETWORK,
+ links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && active.startsWith(l.href)) })),
+ appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Numbers', href: '/stats', icon: 'fa-chart-line' }, { label: 'Rundown', href: '/rundown', icon: 'fa-newspaper' }],
+ history: { type: 'page', title: title || NAME },
+ };
+ const footer = { service: 'report', variant: 'full', mount: '#ov-footer', brandName: NAME };
+ return shell.page({
+ name: NAME,
+ lang: 'en',
+ title: title || `${NAME}: ${ROOM.line}`,
+ titleSuffix: title ? ` · ${NAME}` : '',
+ siteName: NAME,
+ description: description || 'The network\'s numbers open to everyone, what streamers talked about in The Rundown, and every stream\'s after-show report.',
+ canonical,
+ robots,
+ jsonLd,
+ home: '/',
+ navLinks: NAV,
+ navbar,
+ footer,
+ head: `${appIcon.headTags({ site: 'report' })}
+<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="${esc(asset('css/app.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="report@${esc(RELEASE)}">`,
+ body: `<a class="skip" href="#main">Skip to content</a>
+<div id="navbar-mount"></div>
+<main id="main" class="rp ov-room" style="${rooms.style('report')}"><div class="rp-wrap">${body}</div></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) { /* optional */ } });</script>`,
+ });
+}
+
+// ── Charts: server-drawn SVG, crisp at any size, each with its numbers for screen readers ──
+function bars(points, label, { h = 140 } = {}) {
+ const pts = (points || []).filter((p) => p && n(p.n) != null);
+ if (pts.length < 2) return '';
+ const max = Math.max(1, ...pts.map((p) => Number(p.n)));
+ const w = 100 / pts.length;
+ const cols = pts.map((p, i) => { const v = Number(p.n); const bh = Math.max(1.5, (v / max) * (h - 18)); return `<rect x="${(i * w + w * 0.14).toFixed(2)}%" y="${(h - bh).toFixed(1)}" width="${(w * 0.72).toFixed(2)}%" height="${bh.toFixed(1)}" rx="3"><title>${esc(day(p.day))}: ${exact(v)}</title></rect>`; }).join('');
+ const first = pts[0], last = pts[pts.length - 1];
+ return `<figure class="rp-bars"><svg viewBox="0 0 100 ${h}" preserveAspectRatio="none" role="img" aria-label="${esc(`${label}: ${pts.map((p) => `${day(p.day)} ${exact(p.n)}`).join(', ')}`)}">${cols}</svg>`
+ + `<figcaption><span>${esc(day(first.day))}</span><span>${esc(day(last.day))}</span></figcaption></figure>`;
+}
+function spark(points) {
+ const vals = (points || []).map((p) => n(p && p.n)).filter((v) => v != null);
+ if (vals.length < 2) return '';
+ const max = Math.max(1, ...vals), W = 240, H = 56;
+ const xy = vals.map((v, i) => [(i / (vals.length - 1)) * W, H - 4 - (v / max) * (H - 10)]);
+ const line = xy.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`).join(' ');
+ return `<svg class="rp-spark" viewBox="0 0 ${W} ${H}" aria-hidden="true"><path class="rp-spark-area" d="${line} L${W} ${H} L0 ${H}Z"/><path class="rp-spark-line" d="${line}"/></svg>`;
+}
+/** A stream's viewers minute by minute (Live's curve: [time, viewers, chat messages]). */
+function curve(points) {
+ const pts = (points || []).filter((p) => Array.isArray(p) && ts(p[0]) != null);
+ if (pts.length < 2) return '';
+ const W = 600, H = 150, t0 = ts(pts[0][0]), t1 = ts(pts[pts.length - 1][0]);
+ const maxV = Math.max(1, ...pts.map((p) => Number(p[1]) || 0)), maxC = Math.max(1, ...pts.map((p) => Number(p[2]) || 0));
+ const X = (t) => ((t - t0) / Math.max(1, t1 - t0)) * W;
+ const view = pts.map((p, i) => `${i ? 'L' : 'M'}${X(ts(p[0])).toFixed(1)} ${(H - 8 - ((Number(p[1]) || 0) / maxV) * (H - 24)).toFixed(1)}`).join(' ');
+ const chat = pts.map((p) => { const c = Number(p[2]) || 0; if (!c) return ''; const bh = (c / maxC) * 36; return `<rect x="${(X(ts(p[0])) - 1.5).toFixed(1)}" y="${(H - bh).toFixed(1)}" width="3" height="${bh.toFixed(1)}" rx="1"/>`; }).join('');
+ return `<figure class="rp-curve"><svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="${esc(`Viewers over the stream, peak ${exact(maxV)}; chat messages as bars`)}"><g class="rp-curve-chat">${chat}</g><path class="rp-curve-area" d="${view} L${W} ${H} L0 ${H}Z"/><path class="rp-curve-line" d="${view}"/></svg>`
+ + `<figcaption><span>${esc(dayTime(pts[0][0]))}</span><span class="rp-legend"><i class="is-v"></i>viewers <i class="is-c"></i>chat</span><span>${esc(new Date(t1).toISOString().slice(11, 16))} UTC</span></figcaption></figure>`;
+}
+
+const tile = (label, value, sub = '', extra = '') => (value == null || value === '' ? '' : `<div class="rp-tile"><span class="rp-tile-l">${esc(label)}</span><b class="rp-tile-v">${value}</b>${sub ? `<span class="rp-tile-s">${sub}</span>` : ''}${extra}</div>`);
+const KIND = { games: 'Games', chat: 'Chat', irl: 'IRL', news: 'News', tech: 'Tech', music: 'Music', art: 'Art' };
+
+/** A Rundown take or segment as a row: the AI's title, what was said (AI), the streamer and when. */
+function takeRow(t) {
+ if (!t) return '';
+ const sid = t.stream_id || (t.stream && t.stream.id);
+ const by = who(t.user || (t.stream && t.stream.user));
+ return `<li class="rp-take"><h3>${sid ? `<a href="/rundown/e/${encodeURIComponent(sid)}">${esc(t.title)}</a>` : esc(t.title)}</h3>${t.what ? `<p>${esc(t.what)}</p>` : ''}`
+ + `<p class="rp-meta">${by ? `<a href="/rundown/s/${encodeURIComponent((t.user || (t.stream && t.stream.user) || {}).username || '')}">${esc(by)}</a>` : ''}${t.at || t.started_at ? `<time>${esc(dayTime(t.at || t.started_at))}</time>` : ''}</p></li>`;
+}
+function topicCard(t) {
+ if (!t || !t.slug) return '';
+ return `<li><a class="rp-topic" href="/rundown/t/${encodeURIComponent(t.slug)}"><span class="rp-kind">${esc(KIND[t.kind] || t.kind || 'Topic')}</span><b>${esc(t.title)}</b>${t.headline ? `<span class="rp-topic-h">${esc(t.headline)}</span>` : ''}`
+ + `<span class="rp-meta">${[n(t.takes) ? `${exact(t.takes)} takes` : '', n(t.streamers) ? `${exact(t.streamers)} streamer${Number(t.streamers) === 1 ? '' : 's'}` : '', t.last_seen_at ? day(t.last_seen_at) : ''].filter(Boolean).map(esc).join(' · ')}</span></a></li>`;
+}
+function streamCard(s) {
+ if (!s || !s.stream_id) return '';
+ const ep = s.episode && s.episode.title ? s.episode : null;
+ return kit.card({ href: `/after-show/${s.stream_id}`, title: ep ? ep.title : s.title || 'Stream', peek: ep ? (ep.logline || s.title || '') : '', type: 'stream', live: !!s.live, corner: n(s.minutes) ? `${s.minutes} min` : '', who: who(s.user), avatar: s.user && s.user.avatar_url, when: s.started_at ? ts(s.started_at) : null, image: s.thumbnail_url || '', count: n(s.takes_total) ? [s.takes_total, 'takes'] : null });
+}
+
+// ── Pages ──
+function home({ stats, rundown, topics } = {}) {
+ const T = (stats && stats.totals) || {};
+ const L = (stats && stats.live) || {};
+ const R = rundown || {};
+ const streams = (R.streams || []).slice(0, 10).map(streamCard).filter(Boolean);
+ const trending = ((topics && topics.topics) || R.trending || []).slice(0, 6);
+ const lead = R.lead;
+ const leadHTML = lead ? `<section class="rp-lead" aria-labelledby="rp-lead-h"><p class="rp-kicker">The Rundown · lead</p><h2 id="rp-lead-h">${lead.stream_id || (lead.stream && lead.stream.id) ? `<a href="/rundown/e/${encodeURIComponent(lead.stream_id || lead.stream.id)}">${esc(lead.title)}</a>` : esc(lead.title)}</h2>`
+ + `${lead.what ? `<p class="rp-lead-what">${esc(lead.what)}</p>` : ''}${lead.take ? `<p class="rp-lead-take">${esc(lead.take)}</p>` : ''}<p class="rp-meta">${AI}${who(lead.user) ? ` · ${esc(who(lead.user))}` : ''}</p></section>` : '';
+ const topicsHTML = trending.length ? `<section aria-labelledby="rp-tr-h"><div class="rp-head-row"><h2 id="rp-tr-h">What streamers talked about</h2><a class="rp-more" href="/rundown">The Rundown${kit.icon('chevron')}</a></div><ul class="rp-topics">${trending.map(topicCard).join('')}</ul></section>` : '';
+ const pv = stats && stats.pageviews_daily;
+ const numbersScene = pv && pv.length > 1 ? {
+ title: 'Every number says what it counts',
+ text: `${stats.note || 'Counts only, never about a person.'} The same numbers are open JSON for anyone to reuse.`,
+ html: `<div class="rp-scene-num"><span class="rp-tile-l">Page views, last ${esc(stats.window_days || 7)} days</span><b>${exact(T.pageviews)}</b>${pct(T.change_pct)}${spark(pv)}</div>`,
+ action: { href: '/stats', label: 'All the numbers' },
+ } : null;
+ const body = sc.room({
+ room: 'report',
+ hero: {
+ title: ROOM.line,
+ lede: 'The network\'s numbers, open to everyone. What streamers talked about, written up. Every stream\'s after-show report. Each number with what it counts and when.',
+ start: { href: '/stats', label: 'See the numbers' },
+ chips: [{ label: 'Last 7 days', href: '/stats?days=7' }, { label: 'Last 30 days', href: '/stats?days=30' }, { label: 'The Rundown', href: '/rundown' }],
+ },
+ proof: [
+ { value: n(T.pageviews), label: 'page views this week', asOf: stats && stats.generated_at ? day(stats.generated_at) : null },
+ { value: n(T.accounts), label: 'accounts' },
+ { value: n(L.streams), label: 'streams so far' },
+ { value: n(R.counts && R.counts.takes), label: 'takes written up' },
+ ].filter((p) => p.value),
+ rails: streams.length ? [{ id: 'after-show', title: 'After-show reports', more: { href: '/rundown', label: 'The Rundown' }, items: streams }] : [],
+ html: leadHTML + topicsHTML,
+ scenes: [numbersScene].filter(Boolean),
+ explore: { title: 'More on Report', items: [
+ { title: 'Every site\'s numbers', text: 'Page views, people, streams and how fast each service answers', href: '/stats', icon: 'ov:gauge' },
+ { title: 'The Rundown', text: 'What streamers talked about, topic by topic', href: '/rundown', icon: 'ov:news' },
+ { title: 'Status', text: 'Whether every OpenVibe service is up right now', href: 'https://openvibe.network/status', icon: 'ov:ping' },
+ ] },
+ dev: { title: 'For developers', lede: 'Every number here is open JSON, readable from any site.', samples: [
+ { label: 'The network, last 30 days', lang: 'bash', code: 'curl "https://openvibe.report/api/v1/stats?days=30"' },
+ { label: 'The Rundown', lang: 'bash', code: 'curl https://openvibe.report/api/v1/rundown/topics' },
+ ], links: [{ label: 'The API', href: '/developers' }, { label: 'llms.txt', href: '/llms.txt' }] },
+ faq: { items: [
+ ['Where do these numbers come from?', 'From the services themselves: page views from the shared navbar\'s anonymous counter and OpenVibe.Live\'s own analytics, traffic from the servers\' metrics, streams and chat from Live. Each page says what a number counts and when it was read.'],
+ ['Is anything here about me?', 'No. Every number is a count. The Rundown and the after-show reports are about public streams, and a streamer can opt out of The Rundown.'],
+ ['Who writes the after-show reports?', 'An AI, from the stream\'s own numbers, chat and speech, and it says so on every report. It never describes how anyone looks.'],
+ ] },
+ });
+ return layout({ title: null, body, active: '/', path: '/' });
+}
+
+function stats({ stats: d, days = 30 } = {}) {
+ const T = d.totals || {}, L = d.live, C = d.coins, A = d.accounts;
+ const tabs = [7, 30, 90].map((x) => `<a href="/stats?days=${x}"${Number(days) === x ? ' aria-current="page"' : ''}>${x} days</a>`).join('');
+ const sites = (d.sites || []).filter((s) => s && s.name).map((s) => `<div class="rp-tr" role="row"><span role="cell" class="rp-site"><a href="${esc(s.url || '#')}">${esc(s.name || s.short)}</a><small>${esc(s.host || '')}</small></span>`
+ + `<span role="cell" data-l="Page views">${s.pageviews == null ? '—' : exact(s.pageviews)} ${pct(s.change_pct)}</span>`
+ + `<span role="cell" data-l="Visitors">${s.unique_visitors == null ? '—' : exact(s.unique_visitors)}</span>`
+ + `<span role="cell" data-l="Requests, 24 h">${s.traffic_24h && s.traffic_24h.requests != null ? fmt(s.traffic_24h.requests) : '—'}</span>`
+ + `<span role="cell" data-l="95% answered in">${s.traffic_24h && s.traffic_24h.p95_ms != null ? `${exact(s.traffic_24h.p95_ms)} ms` : '—'}</span></div>`).join('');
+ const body = `<header class="rp-page-h"><p class="rp-kicker">Open numbers</p><h1>Every OpenVibe site's numbers</h1>
+<p class="rp-lede">Public for everyone, counts only, never about a person.${d.generated_at ? ` Read ${esc(dayTime(d.generated_at))}.` : ''}</p><nav class="rp-tabs" aria-label="Time range">${tabs}</nav></header>
+<section class="rp-tiles" aria-label="Across the network">
+${tile(`Page views, ${d.window_days || days} days`, exact(T.pageviews), pct(T.change_pct), spark(d.pageviews_daily))}
+${A ? tile('Accounts', exact(A.total), A.new != null ? `+${exact(A.new)} in ${d.window_days || days} days` : '') : ''}
+${T.services ? tile('Services up', `${exact(T.services.up)}<small> of ${exact(T.services.checked)}</small>`, '<a href="https://openvibe.network/status">Status</a>') : ''}
+${T.requests_24h != null ? tile('Requests, 24 h', fmt(T.requests_24h), T.sites_open ? `${exact(T.sites_open)} sites` : '') : ''}
+${L ? tile('Live right now', exact(L.live_now), L.watching != null ? `${exact(L.watching)} watching` : '') : ''}
+${C ? tile('OpenCoins earned', fmt(C.earned), C.earned_window != null ? `+${fmt(C.earned_window)} in ${d.window_days || days} days` : '') : ''}
+</section>
+${d.pageviews_daily && d.pageviews_daily.length > 1 ? `<section aria-labelledby="rp-pv-h"><h2 id="rp-pv-h">Page views a day</h2>${bars(d.pageviews_daily, 'Page views a day')}</section>` : ''}
+${sites ? `<section aria-labelledby="rp-sites-h"><h2 id="rp-sites-h">Every site</h2><p class="rp-fine">Most visited first over ${esc(d.window_days || days)} days; requests and speed (95% of answers were at least this fast) over the last 24 hours.</p>
+<div class="rp-table" role="table" aria-label="Every site"><div class="rp-tr rp-th" role="row"><span role="columnheader">Site</span><span role="columnheader">Page views</span><span role="columnheader">Visitors</span><span role="columnheader">Requests, 24 h</span><span role="columnheader">95% answered in</span></div>${sites}</div></section>` : ''}
+${L ? `<section aria-labelledby="rp-live-h"><h2 id="rp-live-h">OpenVibe.Live</h2><div class="rp-minis">${[['Streamers', L.streamers], ['Streams', L.streams], ['Hours watched', L.hours_watched], ['Chat messages', L.messages], ['Recordings', L.vods], ['Clips', L.clips], ['AI moments', L.ai_moments]].filter(([, v]) => n(v)).map(([l, v]) => `<div><b>${fmt(v)}</b><span>${esc(l)}</span></div>`).join('')}</div></section>` : ''}
+${A && A.daily && A.daily.length > 1 ? `<section aria-labelledby="rp-acc-h"><h2 id="rp-acc-h">New accounts a day</h2>${bars(A.daily, 'New accounts a day', { h: 110 })}</section>` : ''}
+<section aria-labelledby="rp-how-h"><h2 id="rp-how-h">How these are counted</h2><p class="rp-fine">${esc(d.note || '')} The same numbers: <a href="/api/v1/stats?days=${esc(days)}"><code>/api/v1/stats</code></a>.</p></section>`;
+ return layout({ title: `Every site's numbers, ${days} days`, description: 'Page views, people, streams and how fast each OpenVibe service answers, open to everyone.', body, active: '/stats', path: `/stats${Number(days) === 30 ? '' : `?days=${Number(days)}`}` });
+}
+
+function rundown({ rundown: R = {}, topics } = {}) {
+ const all = ((topics && topics.topics) || []).slice(0, 24);
+ const onAir = (R.on_air || []).map(streamCard).filter(Boolean);
+ const voices = (R.voices || []).slice(0, 12);
+ const streams = (R.streams || []).slice(0, 12).map(streamCard).filter(Boolean);
+ const body = `<header class="rp-page-h"><p class="rp-kicker">The Rundown</p><h1>What streamers talked about</h1>
+<p class="rp-lede">Written up from what was said on stream: topics, takes and how they connect. ${AI}</p>
+${R.counts ? `<p class="rp-counts">${[['topics', R.counts.topics], ['episodes', R.counts.episodes], ['takes', R.counts.takes]].filter(([, v]) => n(v)).map(([l, v]) => `<span><b>${exact(v)}</b> ${l}</span>`).join('')}</p>` : ''}</header>
+${onAir.length ? kit.rail({ id: 'on-air', title: 'On air now', items: onAir }) : ''}
+${R.lead ? `<section class="rp-lead" aria-labelledby="rp-lead-h"><p class="rp-kicker">Lead</p><h2 id="rp-lead-h">${esc(R.lead.title)}</h2>${R.lead.what ? `<p class="rp-lead-what">${esc(R.lead.what)}</p>` : ''}${R.lead.take ? `<p class="rp-lead-take">${esc(R.lead.take)}</p>` : ''}</section>` : ''}
+${all.length ? `<section aria-labelledby="rp-t-h"><h2 id="rp-t-h">Topics</h2><ul class="rp-topics">${all.map(topicCard).join('')}</ul></section>` : ''}
+${voices.length ? `<section aria-labelledby="rp-v-h"><h2 id="rp-v-h">Voices</h2><ul class="rp-voices">${voices.map((v) => `<li><a href="/rundown/s/${encodeURIComponent((v.user || {}).username || '')}">${kit.mark({ name: who(v.user), src: v.user && v.user.avatar_url, size: 40 })}<span><b>${esc(who(v.user))}</b><small>${[n(v.topics) ? `${exact(v.topics)} topics` : '', n(v.segments) ? `${exact(v.segments)} takes` : ''].filter(Boolean).join(' · ')}</small></span></a></li>`).join('')}</ul></section>` : ''}
+${streams.length ? kit.rail({ id: 'episodes', title: 'Recent streams', items: streams }) : ''}`;
+ return layout({ title: 'The Rundown: what streamers talked about', description: 'Topics, takes and crossovers from what streamers said on OpenVibe, written up by AI from the streams themselves.', body, active: '/rundown', path: '/rundown' });
+}
+
+function topic({ topic: d } = {}) {
+ const t = d.topic || {};
+ const takes = (d.takes || []).slice(0, 40);
+ const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/rundown">The Rundown</a> · ${esc(KIND[t.kind] || t.kind || 'Topic')}</p><h1>${esc(t.title)}</h1>
+${t.headline ? `<p class="rp-lede">${esc(t.headline)}</p>` : ''}${t.summary ? `<p class="rp-summary">${esc(t.summary)}</p>` : ''}<p class="rp-meta">${AI}${t.first_seen_at ? ` · since ${esc(day(t.first_seen_at))}` : ''}</p></header>
+${takes.length ? `<section aria-labelledby="rp-tk-h"><h2 id="rp-tk-h">Takes</h2><ol class="rp-takes">${takes.map(takeRow).join('')}</ol></section>` : ''}
+${(d.related || []).length ? `<section aria-labelledby="rp-rel-h"><h2 id="rp-rel-h">Related</h2><ul class="rp-chips">${d.related.slice(0, 12).map((r) => `<li><a href="/rundown/t/${encodeURIComponent(r.slug)}">${esc(r.title)}</a></li>`).join('')}</ul></section>` : ''}`;
+ return layout({ title: `${t.title}: The Rundown`, description: t.headline || `What streamers said about ${t.title}.`, body, active: '/rundown', path: `/rundown/t/${encodeURIComponent(t.slug || '')}` });
+}
+
+function episode({ episode: d } = {}) {
+ const e = d.episode || {}, s = d.stream || {};
+ const segs = (d.segments || []).slice(0, 60);
+ const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/rundown">The Rundown</a> · episode</p><h1>${esc(e.title || s.title || 'Episode')}</h1>${e.logline ? `<p class="rp-lede">${esc(e.logline)}</p>` : ''}
+<p class="rp-meta">${AI}${who(s.user) ? ` · <a href="/rundown/s/${encodeURIComponent(s.user.username)}">${esc(who(s.user))}</a>` : ''}${s.started_at ? ` · ${esc(dayTime(s.started_at))}` : ''}${s.id ? ` · <a href="/after-show/${encodeURIComponent(s.id)}">After-show report</a>` : ''}</p></header>
+${segs.length ? `<section aria-labelledby="rp-sg-h"><h2 id="rp-sg-h">What happened</h2><ol class="rp-takes rp-timeline">${segs.map(takeRow).join('')}</ol></section>` : ''}
+${(d.more || []).length ? `<section aria-labelledby="rp-mo-h"><h2 id="rp-mo-h">More from ${esc(who(s.user) || 'this streamer')}</h2><ul class="rp-chips">${d.more.slice(0, 8).map((m) => `<li><a href="/rundown/e/${encodeURIComponent(m.stream_id)}">${esc(m.title)}</a></li>`).join('')}</ul></section>` : ''}`;
+ return layout({ title: `${e.title || s.title}: The Rundown`, description: e.logline || '', body, active: '/rundown', path: `/rundown/e/${encodeURIComponent(s.id || e.stream_id || '')}` });
+}
+
+function speaker({ speaker: d, username } = {}) {
+ const u = d.user || { username };
+ if (d.opted_out) return layout({ title: `${who(u)}: The Rundown`, robots: 'noindex, follow', body: `<header class="rp-page-h"><h1>${esc(who(u))}</h1><p class="rp-lede">${esc(who(u))} has opted out of The Rundown: nothing they say on stream is written up here.</p></header>`, active: '/rundown' });
+ const body = `<header class="rp-page-h rp-person">${kit.mark({ name: who(u), src: u.avatar_url, size: 64 })}<div><p class="rp-kicker">The Rundown · voice</p><h1>${esc(who(u))}</h1><p class="rp-meta"><a href="https://openvibe.live/@${encodeURIComponent(u.username || username)}">Channel on OpenVibe.Live</a> · <a href="/c/${encodeURIComponent(u.username || username)}">After-show reports</a></p></div></header>
+${(d.topics || []).length ? `<section aria-labelledby="rp-st-h"><h2 id="rp-st-h">Topics</h2><ul class="rp-topics">${d.topics.slice(0, 12).map(topicCard).join('')}</ul></section>` : ''}
+${(d.lines || []).length ? `<section aria-labelledby="rp-sl-h"><h2 id="rp-sl-h">Takes</h2><ol class="rp-takes">${d.lines.slice(0, 20).map(takeRow).join('')}</ol><p class="rp-meta">${AI}</p></section>` : ''}
+${(d.episodes || []).length ? `<section aria-labelledby="rp-se-h"><h2 id="rp-se-h">Episodes</h2><ul class="rp-chips">${d.episodes.slice(0, 12).map((m) => `<li><a href="/rundown/e/${encodeURIComponent(m.stream_id)}">${esc(m.title)}</a></li>`).join('')}</ul></section>` : ''}`;
+ return layout({ title: `${who(u)}: The Rundown`, description: `What ${who(u)} talked about on stream, topic by topic.`, body, active: '/rundown', path: `/rundown/s/${encodeURIComponent(u.username || username || '')}` });
+}
+
+function recap({ recap: d } = {}) {
+ const r = d.recap || d;
+ const s = r.stream || {}, u = r.streamer || {}, v = r.viewers || {}, c = r.chat || {}, love = r.love || {}, w = r.write || {}, vod = r.vod;
+ const takes = (r.takes || []).filter((t) => t && t.title).sort((a, b) => (n(a.sec) ?? 1e9) - (n(b.sec) ?? 1e9)).slice(0, 8);
+ const at = (sec) => (vod && vod.id && n(sec) != null ? `https://openvibe.video/v/${encodeURIComponent(vod.id)}?t=${Math.max(0, Math.floor(Number(sec)))}` : null);
+ const body = `<header class="rp-page-h rp-recap-h"><p class="rp-kicker">After-show report</p><h1>${esc(w.headline || s.title || 'Stream')}</h1>
+<p class="rp-meta">${kit.mark({ name: who(u), src: u.avatar_url, size: 28 })} <a href="https://openvibe.live/@${encodeURIComponent(u.username || '')}">${esc(who(u))}</a>${s.title && w.headline ? ` · ${esc(s.title)}` : ''}${s.started_at ? ` · ${esc(dayTime(s.started_at))}` : ''}${n(s.duration_seconds) ? ` · ${esc(mins(s.duration_seconds))}` : ''}</p>
+${w.summary ? `<p class="rp-summary">${esc(w.summary)}</p><p class="rp-meta">${AI}</p>` : ''}</header>
+<section class="rp-tiles" aria-label="The stream in numbers">
+${tile('Peak viewers', n(s.peak_viewers) != null ? exact(s.peak_viewers) : null, v.peak_at ? esc(dayTime(v.peak_at)) : '')}
+${tile('Average viewers', n(v.avg) != null ? exact(v.avg) : null)}
+${tile('Chat messages', n(c.messages) ? exact(c.messages) : null, n(c.chatters) ? `${exact(c.chatters)} chatting` : '')}
+${tile('New followers', n(love.follows) ? exact(love.follows) : null, n(love.followers_now) ? `${exact(love.followers_now)} now` : '')}
+${tile('Tips', n(love.tips) ? exact(love.tips) : null)}
+${tile('Length', n(s.duration_seconds) ? esc(mins(s.duration_seconds)) : null)}
+</section>
+${curve(v.curve) ? `<section aria-labelledby="rp-cv-h"><h2 id="rp-cv-h">Minute by minute</h2>${curve(v.curve)}</section>` : ''}
+${takes.length ? `<section aria-labelledby="rp-rt-h"><h2 id="rp-rt-h">The moments</h2><ol class="rp-moments">${takes.map((t) => `<li>${at(t.sec) ? `<a class="rp-at" href="${esc(at(t.sec))}">${kit.icon('play')}<span>${esc(mins(t.sec) || '0 min')}</span></a>` : ''}<span><b>${esc(t.title)}</b>${t.topic ? `<a class="rp-kind" href="/rundown/t/${encodeURIComponent(t.slug || '')}">${esc(t.topic)}</a>` : ''}</span></li>`).join('')}</ol><p class="rp-meta">${AI}</p></section>` : ''}
+${(c.top || []).length ? `<section aria-labelledby="rp-ch-h"><h2 id="rp-ch-h">Most active in chat</h2><ul class="rp-chips">${c.top.slice(0, 8).map((x) => `<li><span>${esc(x.display_name || x.username)} <b>${exact(x.n)}</b></span></li>`).join('')}</ul></section>` : ''}
+<p class="rp-act">${vod && vod.id ? `<a class="rp-btn" href="https://openvibe.video/v/${encodeURIComponent(vod.id)}">Watch the recording</a>` : ''} <a class="rp-btn is-quiet" href="/c/${encodeURIComponent(u.username || '')}">More from ${esc(who(u))}</a>${s.id ? ` <a class="rp-btn is-quiet" href="/rundown/e/${encodeURIComponent(s.id)}">In The Rundown</a>` : ''}</p>`;
+ return layout({ title: `${w.headline || s.title}: after-show report`, description: w.summary || `How ${who(u)}'s stream went, in numbers.`, body, active: '/rundown', path: `/after-show/${encodeURIComponent(s.id || '')}` });
+}
+
+function channel({ username, recaps } = {}) {
+ const list = (recaps && recaps.recaps) || [];
+ const cards = list.slice(0, 30).map((x) => kit.card({ href: `/after-show/${x.stream_id}`, title: x.headline || x.title || 'Stream', peek: x.headline && x.title ? x.title : '', type: 'stream', image: x.thumbnail_url || '', badge: x.grade ? `Grade ${x.grade}` : '', corner: n(x.duration_seconds) ? mins(x.duration_seconds) : '', count: n(x.peak_viewers) ? [x.peak_viewers, 'peak'] : null, when: x.ended_at ? ts(x.ended_at) : null }));
+ const body = `<header class="rp-page-h"><p class="rp-kicker">After-show reports</p><h1>${esc(username)}</h1><p class="rp-meta"><a href="https://openvibe.live/@${encodeURIComponent(username)}">Channel on OpenVibe.Live</a> · <a href="/rundown/s/${encodeURIComponent(username)}">In The Rundown</a></p></header>
+${cards.length ? `<ul class="rp-grid">${cards.map((c) => `<li>${c}</li>`).join('')}</ul>` : kit.empty({ title: 'No reports yet', text: 'A report is written a few minutes after each stream ends.' })}`;
+ return layout({ title: `${username}: after-show reports`, description: `How each of ${username}'s streams went.`, body, active: '/rundown', path: `/c/${encodeURIComponent(username)}` });
+}
+
+function developers() {
+ const rows = [
+ ['GET /api/v1/stats?days=30', 'The network\'s open numbers over 7, 30 or 90 days: page views, accounts, Live, OpenCoins and every site.'],
+ ['GET /api/v1/rundown', 'The Rundown\'s front: on air, the lead, recent streams, trending topics, voices and counts.'],
+ ['GET /api/v1/rundown/topics', 'Every topic with its headline, takes and streamers.'],
+ ['GET /api/v1/rundown/t/:slug', 'One topic with its takes and related topics.'],
+ ['GET /api/v1/after-show/:streamId', 'A stream\'s after-show report: viewers minute by minute, chat, moments and the write-up.'],
+ ['GET /api/v1/c/:username', 'A channel\'s after-show reports, newest first.'],
+ ];
+ const body = `<header class="rp-page-h"><p class="rp-kicker">For developers</p><h1>The Report API</h1><p class="rp-lede">Open JSON, no key, readable from any site (CORS). Counts only, never about a person.</p></header>
+<section aria-labelledby="rp-ep-h"><h2 id="rp-ep-h">Endpoints</h2><dl class="rp-eps">${rows.map(([ep, what]) => `<div><dt><code>${esc(ep)}</code></dt><dd>${esc(what)}</dd></div>`).join('')}</dl></section>
+<p class="rp-fine">Limits: 120 requests a minute from one address. Errors are problem+json (RFC 9457). <a href="/llms.txt">llms.txt</a> describes the site for agents.</p>`;
+ return layout({ title: 'The Report API', description: 'Open JSON for the network\'s numbers, The Rundown and after-show reports.', body, active: '/developers', path: '/developers' });
+}
+
+function notFound() {
+ return layout({ title: 'Not found', robots: 'noindex, follow', body: `<header class="rp-page-h"><h1>Nothing here</h1><p class="rp-lede">That page does not exist, or its source has nothing for it. <a href="/">Back to Report</a>.</p></header>` });
+}
+
+module.exports = { home, stats, rundown, topic, episode, speaker, recap, channel, developers, notFound, layout, setRelease, configure };
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+
+/**
+ * The JSON API: every route answers the source's document as it came, CORS-open and public for two minutes; a bad
+ * parameter or a missing source is a 404 problem+json without a source call; the per-address limit answers 429.
+ */
+const assert = require('assert');
+const { boot, check, done, defaults } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('every API route answers the source JSON with CORS and the two-minute cache', async () => {
+ const d = defaults();
+ const routes = [
+ ['/api/v1/stats?days=30', d.stats],
+ ['/api/v1/rundown', d.rundown],
+ ['/api/v1/rundown/topics', d.topics],
+ ['/api/v1/rundown/t/seattle', d.topic],
+ ['/api/v1/after-show/7', d.recap],
+ ['/api/v1/c/goosely', d.channel],
+ ];
+ for (const [path, expected] of routes) {
+ const r = await t.get(path);
+ assert.strictEqual(r.status, 200, path);
+ assert.match(r.headers.get('content-type') || '', /^application\/json/);
+ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=120');
+ assert.deepStrictEqual(r.json(), expected, path);
+ }
+ });
+
+ await check('a bad parameter or an unknown route is a 404 problem+json, still CORS-open', async () => {
+ const before = t.fetchImpl.calls.length;
+ for (const path of ['/api/v1/rundown/t/Not_A_Slug', '/api/v1/after-show/0', '/api/v1/c/bad-name', '/api/v1/nope']) {
+ const r = await t.get(path);
+ assert.strictEqual(r.status, 404, path);
+ assert.match(r.headers.get('content-type') || '', /^application\/problem\+json/);
+ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
+ const body = r.json();
+ assert.strictEqual(body.status, 404);
+ assert.ok(body.code, 'no problem code');
+ assert.ok(body.type, 'no problem type');
+ }
+ const outside = await t.get('/api/nope');
+ assert.strictEqual(outside.status, 404);
+ assert.match(outside.headers.get('content-type') || '', /^application\/problem\+json/);
+ assert.strictEqual(t.fetchImpl.calls.length, before, 'a bad parameter reached the source');
+ });
+
+ await check('the API is read-only', async () => {
+ const r = await t.get('/api/v1/stats', { method: 'POST' });
+ assert.strictEqual(r.status, 405);
+ assert.match(r.headers.get('content-type') || '', /^application\/problem\+json/);
+ assert.strictEqual(r.headers.get('allow'), 'GET, HEAD');
+ });
+
+ await check('a source that answers nothing is a 404 problem+json', async () => {
+ const empty = await boot({ data: { stats: null, recap: null } });
+ try {
+ for (const path of ['/api/v1/stats', '/api/v1/after-show/7']) {
+ const r = await empty.get(path);
+ assert.strictEqual(r.status, 404, path);
+ assert.match(r.headers.get('content-type') || '', /^application\/problem\+json/);
+ }
+ } finally { await empty.close(); }
+ });
+ } finally {
+ await t.close();
+ }
+
+ await check('120 (or the configured number of) requests a minute per address, then a 429 problem', async () => {
+ const limited = await boot({ env: { REPORT_API_RATE_LIMIT_PER_MIN: '2' } });
+ try {
+ assert.strictEqual((await limited.get('/api/v1/rundown/topics')).status, 200);
+ assert.strictEqual((await limited.get('/api/v1/rundown/topics')).status, 200);
+ const third = await limited.get('/api/v1/rundown/topics');
+ assert.strictEqual(third.status, 429);
+ assert.match(third.headers.get('content-type') || '', /^application\/problem\+json/);
+ assert.strictEqual(third.json().code, 'rate_limited');
+ assert.strictEqual(third.headers.get('access-control-allow-origin'), '*');
+ } finally { await limited.close(); }
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,159 @@
@@ -0,0 +1,159 @@
+'use strict';
+
+/**
+ * The two source clients: a cache hit is one fetch, an expiry is another, a failure is null with one short log
+ * line, and a path parameter that cannot exist is refused before any fetch. The clock is injected; no test waits.
+ */
+const assert = require('assert');
+const { createNetworkClient } = require('../server/data/network');
+const { createLiveClient } = require('../server/data/live');
+const { isSlug, isStreamId, isUsername, normaliseDays } = require('../server/data/source');
+const { jsonResponse, check, done } = require('./helpers/boot');
+
+const quiet = () => ({ warn() {}, error() {} });
+
+(async () => {
+ await check('network stats: the same window twice is one fetch, and the entry expires after five minutes', async () => {
+ let now = 1_000_000;
+ let calls = 0;
+ const client = createNetworkClient({
+ config: { networkUrl: 'http://network' },
+ fetchImpl: async () => { calls++; return jsonResponse({ window_days: 7, totals: { pageviews: 1 } }); },
+ now: () => now,
+ log: quiet(),
+ });
+ const first = await client.stats(7);
+ const second = await client.stats(7);
+ assert.deepStrictEqual(first, { window_days: 7, totals: { pageviews: 1 } });
+ assert.strictEqual(first, second);
+ assert.strictEqual(calls, 1, 'a cache hit must not fetch');
+ now += 5 * 60_000 + 1;
+ await client.stats(7);
+ assert.strictEqual(calls, 2, 'an expired entry must fetch again');
+ });
+
+ await check('network stats: days other than 7, 30 or 90 become 30, in the request and in the cache key', async () => {
+ const urls = [];
+ const client = createNetworkClient({
+ config: { networkUrl: 'http://network' },
+ fetchImpl: async (url) => { urls.push(String(url)); return jsonResponse({ window_days: 30 }); },
+ now: () => 0,
+ log: quiet(),
+ });
+ await client.stats('90');
+ await client.stats(31);
+ await client.stats('nonsense');
+ assert.deepStrictEqual(urls, ['http://network/api/v1/stats?days=90', 'http://network/api/v1/stats?days=30']);
+ assert.strictEqual(normaliseDays('7'), 7);
+ assert.strictEqual(normaliseDays(30), 30);
+ assert.strictEqual(normaliseDays('31'), 30);
+ });
+
+ await check('a source failure is null, never a throw, with one short log line', async () => {
+ const lines = [];
+ const client = createLiveClient({
+ config: { liveUrl: 'http://live' },
+ fetchImpl: async () => new Response('down', { status: 503 }),
+ log: { warn: (line) => lines.push(String(line)), error: (line) => lines.push(String(line)) },
+ });
+ assert.strictEqual(await client.rundown(), null);
+ assert.strictEqual(lines.length, 1);
+ assert.match(lines[0], /\[OpenVibe\.Report\] live rundown failed: HTTP 503/);
+
+ const thrown = createLiveClient({
+ config: { liveUrl: 'http://live' },
+ fetchImpl: async () => { throw new Error('connect ECONNREFUSED'); },
+ log: { warn: (line) => lines.push(String(line)) },
+ });
+ assert.strictEqual(await thrown.recap(12), null);
+ assert.strictEqual(lines.length, 2);
+ assert.match(lines[1], /live recap failed: connect ECONNREFUSED/);
+ });
+
+ await check('live: a slug, stream id or username that cannot exist is refused before any fetch', async () => {
+ let calls = 0;
+ const client = createLiveClient({
+ config: { liveUrl: 'http://live' },
+ fetchImpl: async () => { calls++; return jsonResponse({}); },
+ log: quiet(),
+ });
+ assert.strictEqual(await client.topic('UPPER'), null);
+ assert.strictEqual(await client.topic(''), null);
+ assert.strictEqual(await client.topic('a/b'), null);
+ assert.strictEqual(await client.topic('a'.repeat(81)), null);
+ assert.strictEqual(await client.episode(0), null);
+ assert.strictEqual(await client.episode(-1), null);
+ assert.strictEqual(await client.episode('1.5'), null);
+ assert.strictEqual(await client.episode('abc'), null);
+ assert.strictEqual(await client.episode(null), null);
+ assert.strictEqual(await client.speaker('bad name'), null);
+ assert.strictEqual(await client.speaker('a'.repeat(33)), null);
+ assert.strictEqual(await client.speaker('émile'), null);
+ assert.strictEqual(await client.recap(0), null);
+ assert.strictEqual(await client.recap('x'), null);
+ assert.strictEqual(await client.channelRecaps('bad!'), null);
+ assert.strictEqual(calls, 0, 'a bad parameter must never reach the source');
+ assert.strictEqual(isSlug('a-valid-slug'), true);
+ assert.strictEqual(isStreamId('42'), true);
+ assert.strictEqual(isStreamId('0'), false);
+ assert.strictEqual(isUsername('goosely'), true);
+ });
+
+ await check('live: each endpoint has its own TTL and its own URL', async () => {
+ let now = 0;
+ const urls = [];
+ const client = createLiveClient({
+ config: { liveUrl: 'http://live' },
+ fetchImpl: async (url) => { urls.push(String(url)); return jsonResponse({ n: urls.length }); },
+ now: () => now,
+ log: quiet(),
+ });
+ await client.rundown();
+ now += 2 * 60_000 + 1;
+ await client.rundown();
+ assert.strictEqual(urls.filter((u) => u.endsWith('/api/rundown/')).length, 2, 'the home is held for two minutes');
+
+ await client.recap(7);
+ now += 9 * 60_000;
+ await client.recap(7);
+ assert.strictEqual(urls.filter((u) => u.endsWith('/api/recap/7')).length, 1, 'a recap is held for ten minutes');
+ now += 2 * 60_000;
+ await client.recap(7);
+ assert.strictEqual(urls.filter((u) => u.endsWith('/api/recap/7')).length, 2);
+
+ await client.topic('seattle');
+ await client.episode('007');
+ await client.speaker('Goosely');
+ await client.channelRecaps('goosely');
+ assert.ok(urls.includes('http://live/api/rundown/t/seattle'));
+ assert.ok(urls.includes('http://live/api/rundown/e/7'), 'a stream id is normalised to a positive integer');
+ assert.ok(urls.includes('http://live/api/rundown/s/Goosely'));
+ assert.ok(urls.includes('http://live/api/recap/channel/goosely'));
+
+ // speaker is cached by the lower-cased name: the same person under another case is one fetch.
+ const before = urls.filter((u) => u.includes('/api/rundown/s/')).length;
+ await client.speaker('goosely');
+ assert.strictEqual(urls.filter((u) => u.includes('/api/rundown/s/')).length, before);
+ });
+
+ await check('the cache keeps 1000 entries and drops the oldest first', async () => {
+ let calls = 0;
+ const client = createLiveClient({
+ config: { liveUrl: 'http://live' },
+ fetchImpl: async () => { calls++; return jsonResponse({ topic: { slug: 'x' }, takes: [] }); },
+ now: () => 0,
+ log: quiet(),
+ cacheMax: 2,
+ });
+ await client.topic('one');
+ await client.topic('two');
+ await client.topic('three');
+ assert.strictEqual(client.cache.size, 2);
+ assert.strictEqual(client.cache.get('rundown:topic:one'), undefined);
+ assert.notStrictEqual(client.cache.get('rundown:topic:two'), undefined);
+ assert.notStrictEqual(client.cache.get('rundown:topic:three'), undefined);
+ assert.strictEqual(calls, 3);
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+
+/**
+ * The crawl artifacts: robots.txt allows the public pages and disallows the API; sitemap.xml is the home, /stats,
+ * /rundown and every topic from the Rundown; llms.txt names the site and its machine-readable answers. All three
+ * are public for an hour.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./helpers/boot');
+
+const STATUS_UPDATED = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'STATUS.json'), 'utf8')).updated;
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('robots.txt: public pages crawlable, /api/ disallowed, the sitemap named', async () => {
+ const r = await t.get('/robots.txt');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type') || '', /^text\/plain/);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=3600');
+ assert.match(r.text, /^User-agent: \*$/m);
+ assert.ok(r.text.includes('Disallow: /api/'));
+ assert.ok(r.text.includes('Sitemap: https://openvibe.report/sitemap.xml'));
+ for (const p of ['/stats', '/rundown']) assert.ok(!r.text.includes(`Disallow: ${p}`), `${p} must stay crawlable`);
+ });
+
+ await check('sitemap.xml: the home, /stats, /rundown and every topic, with lastmod from STATUS.json', async () => {
+ const r = await t.get('/sitemap.xml');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type') || '', /^application\/xml/);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=3600');
+ for (const p of ['/', '/stats', '/rundown', '/rundown/t/seattle', '/rundown/t/ai-progress']) {
+ assert.ok(r.text.includes(`<loc>https://openvibe.report${p}</loc>`), `no ${p} entry`);
+ }
+ assert.ok(!r.text.includes('<loc>https://openvibe.report/api/'), 'the API must not be in the sitemap');
+ const lastmods = [...r.text.matchAll(/<lastmod>([^<]+)<\/lastmod>/g)].map((m) => m[1]);
+ assert.ok(lastmods.length > 0, 'no lastmod');
+ assert.ok(lastmods.every((d) => d === STATUS_UPDATED), `lastmod must come from STATUS.json (${STATUS_UPDATED})`);
+ });
+
+ await check('llms.txt: the site, its pages and its machine-readable answers', async () => {
+ const r = await t.get('/llms.txt');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type') || '', /^text\/plain/);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=3600');
+ assert.ok(r.text.startsWith('# OpenVibe.Report'));
+ for (const link of [
+ 'https://openvibe.report/',
+ 'https://openvibe.report/stats',
+ 'https://openvibe.report/rundown',
+ 'https://openvibe.report/api/v1/stats?days=7',
+ 'https://openvibe.report/sitemap.xml',
+ ]) assert.ok(r.text.includes(`(${link})`), `missing link ${link}`);
+ });
+
+ await check('a topics source that answers nothing still leaves the three static sitemap pages', async () => {
+ const empty = await boot({ data: { topics: null } });
+ try {
+ const r = await empty.get('/sitemap.xml');
+ assert.strictEqual(r.status, 200);
+ for (const p of ['/', '/stats', '/rundown']) assert.ok(r.text.includes(`<loc>https://openvibe.report${p}</loc>`), p);
+ assert.ok(!r.text.includes('/rundown/t/'), 'no topic should be listed');
+ } finally { await empty.close(); }
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+
+/**
+ * The plumbing: health, truthful readiness (both sources optional, so a dead source is degraded and still 200, never
+ * 503), release metadata, and the content-addressed static assets.
+ */
+const assert = require('assert');
+const { assetVersion } = require('../server/assets');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const healthy = await boot();
+ try {
+ await check('GET /api/health: ok, the service id and the version', async () => {
+ const r = await healthy.get('/api/health');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
+ assert.deepStrictEqual(r.json(), { status: 'ok', service: 'openvibe-report', version: '0.1.0' });
+ });
+
+ await check('GET /api/ready: both sources answer → ready, no database check', async () => {
+ const r = await healthy.get('/api/ready');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
+ const body = r.json();
+ assert.strictEqual(body.ready, true);
+ assert.strictEqual(body.status, 'ready');
+ assert.strictEqual(body.service, 'report');
+ assert.deepStrictEqual(body.degraded, []);
+ assert.strictEqual(body.checks.network.status, 'ok');
+ assert.strictEqual(body.checks.live.status, 'ok');
+ assert.ok(!('db' in body.checks), 'Report has no database');
+ });
+
+ await check('GET /release.json: service report, versioned like every OpenVibe site', async () => {
+ const r = await healthy.get('/release.json');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type') || '', /^application\/json/);
+ const body = r.json();
+ assert.strictEqual(body.service, 'report');
+ assert.strictEqual(body.release, '0000000');
+ assert.ok(body.packages && body.packages['openvibe-shared'], 'no openvibe-shared version');
+ });
+
+ await check('static css and js: public, immutable only with the matching ?v=', async () => {
+ for (const rel of ['css/app.css', 'js/app.js']) {
+ const plain = await healthy.get(`/${rel}`);
+ assert.strictEqual(plain.status, 200);
+ assert.strictEqual(plain.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400');
+ const pinned = await healthy.get(`/${rel}?v=${assetVersion(rel)}`);
+ assert.strictEqual(pinned.status, 200);
+ assert.strictEqual(pinned.headers.get('cache-control'), 'public, max-age=31536000, immutable');
+ }
+ });
+ } finally { await healthy.close(); }
+
+ await check('GET /api/ready: a source that does not answer is degraded, not 503', async () => {
+ const down = await boot({ fetchImpl: async () => { throw new Error('ECONNREFUSED'); } });
+ try {
+ const r = await down.get('/api/ready');
+ assert.strictEqual(r.status, 200, 'a dead optional source must not make the service not_ready');
+ const body = r.json();
+ assert.strictEqual(body.ready, true);
+ assert.strictEqual(body.status, 'degraded');
+ assert.deepStrictEqual(body.failed, []);
+ assert.deepStrictEqual(body.degraded.sort(), ['live', 'network']);
+ assert.strictEqual(body.checks.network.status, 'fail');
+ assert.strictEqual(body.checks.live.status, 'fail');
+ } finally { await down.close(); }
+ });
+
+ await check('GET /api/ready: one source down is degraded, the other still ok', async () => {
+ const partial = await boot({ stub: { health: { network: true, live: false } } });
+ try {
+ const body = (await partial.get('/api/ready')).json();
+ assert.strictEqual(body.ready, true);
+ assert.strictEqual(body.status, 'degraded');
+ assert.deepStrictEqual(body.degraded, ['live']);
+ assert.strictEqual(body.checks.network.status, 'ok');
+ } finally { await partial.close(); }
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,208 @@
@@ -0,0 +1,208 @@
+'use strict';
+
+// Many requests per test file would trip the 120/min per-address limit. The app's limit is configurable, so the
+// tests raise it once through the environment the helper passes to config.load.
+process.env.RELEASE_COMMIT = process.env.RELEASE_COMMIT || '0000000';
+
+/**
+ * Boots OpenVibe.Report on an ephemeral port with a stub fetch (no network, no running site) and returns a small
+ * HTTP client. Each test file gets its own app, clients and captured log lines.
+ *
+ * const t = await boot();
+ * t.get('/api/v1/stats?days=30') → { status, headers, text, json() }
+ * t.logs()
+ */
+const http = require('http');
+const configLib = require('../../server/config');
+const { createApp } = require('../../server/app');
+
+const NETWORK = 'http://127.0.0.1:4000';
+const LIVE = 'http://127.0.0.1:3000';
+
+function jsonResponse(body, status = 200) {
+ return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+}
+
+function defaults() {
+ return {
+ stats: {
+ generated_at: '2026-10-28T12:00:00.000Z',
+ window_days: 7,
+ totals: {
+ pageviews: 77408, pageviews_prev: 102633, change_pct: -25, sites_open: 37,
+ services: { up: 38, checked: 38 }, accounts: 496, requests_24h: 1037412,
+ },
+ sites: [{ name: 'OpenVibe.Live', host: 'openvibe.live', pageviews: 32000, change_pct: 4 }],
+ },
+ rundown: {
+ on_air: [{ stream_id: 7, user: { username: 'goosely', display_name: 'goosely' }, stream_title: 'Seattle', topic: { slug: 'seattle', title: 'Seattle' } }],
+ lead: { title: 'A lead take', what: 'What the lead said.', user: { username: 'goosely', display_name: 'goosely' }, topic: { slug: 'seattle', title: 'Seattle' }, stream_id: 7 },
+ streams: [{ stream_id: 7, user: { username: 'goosely', display_name: 'goosely' }, title: 'Seattle', episode: { title: 'A written-up stream' } }],
+ trending: [{ slug: 'seattle', title: 'Seattle', kind: 'life', takes: 3, streamers: 1 }],
+ counts: { topics: 5, episodes: 4, takes: 20 },
+ },
+ topics: {
+ topics: [
+ { slug: 'seattle', title: 'Seattle', kind: 'life', takes: 3, streamers: 1 },
+ { slug: 'ai-progress', title: 'AI progress', kind: 'tech', takes: 5, streamers: 2 },
+ ],
+ kinds: ['news', 'games', 'tech', 'life'],
+ },
+ topic: {
+ topic: { slug: 'seattle', title: 'Seattle', kind: 'life', headline: 'A headline', summary: 'A summary.', takes: 3, streamers: 1, first_seen_at: '2026-10-20 10:00:00', last_seen_at: '2026-10-27 10:00:00' },
+ takes: [{ title: 'A take', quip: 'A quip.', user: { username: 'goosely', display_name: 'goosely' }, topic: { slug: 'seattle', title: 'Seattle' }, stream_id: 7 }],
+ related: [{ slug: 'ai-progress', title: 'AI progress', kind: 'tech' }],
+ },
+ episode: {
+ episode: { stream_id: 7, title: 'A written-up stream', logline: 'The logline.', minutes: 34, aired_at: '2026-10-27 10:00:00' },
+ stream: { id: 7, title: 'Seattle', is_live: false, user: { username: 'goosely', display_name: 'goosely' } },
+ segments: [{ title: 'A segment', user: { username: 'goosely', display_name: 'goosely' }, topic: { slug: 'seattle', title: 'Seattle' } }],
+ more: [{ stream_id: 6, title: 'An older episode', aired_at: '2026-10-26 10:00:00' }],
+ },
+ speaker: {
+ user: { username: 'goosely', display_name: 'goosely' },
+ opted_out: false,
+ episodes: [{ stream_id: 7, title: 'A written-up stream', aired_at: '2026-10-27 10:00:00', minutes: 34 }],
+ topics: [{ slug: 'seattle', title: 'Seattle', takes: 3 }],
+ lines: [],
+ crossovers: [],
+ },
+ recap: {
+ recap: {
+ stream: { id: 7, title: 'Seattle Today', duration_seconds: 2017, ended_at: '2026-10-27 10:33:00' },
+ streamer: { username: 'goosely', display_name: 'goosely' },
+ viewers: { avg: 1 },
+ chat: { messages: 53, chatters: 2 },
+ write: { headline: 'A tiny but mighty vibe', summary: 'What happened, in a paragraph.' },
+ generated_at: '2026-10-27T10:35:00.000Z',
+ },
+ more: [],
+ },
+ channel: {
+ recaps: [{ stream_id: 7, title: 'Seattle Today', headline: 'A tiny but mighty vibe', grade: 'A', duration_seconds: 2017, ended_at: '2026-10-27 10:33:00' }],
+ },
+ };
+}
+
+/**
+ * A URL-aware stand-in for OpenVibe.Network and OpenVibe.Live. `data` overrides any of the default answers; a
+ * null answer becomes a 404, exactly as a source that has nothing. `health` says which source's /api/health
+ * answers: { network: true, live: false } makes only Live fail.
+ */
+function stubFetch(data = {}, { health = { network: true, live: true } } = {}) {
+ const answers = { ...defaults(), ...data };
+ const calls = [];
+
+ const fetchImpl = async (url) => {
+ const raw = String(url);
+ calls.push(raw);
+ const u = new URL(raw);
+ const p = u.pathname;
+ const port = u.port;
+
+ if (p.endsWith('/api/health')) {
+ const source = port === '3000' ? 'live' : 'network';
+ if (!health[source]) throw new Error(`${source} health failed`);
+ return jsonResponse({ status: 'ok' });
+ }
+
+ if (p.endsWith('/api/v1/stats')) {
+ return answers.stats == null ? jsonResponse({ error: 'unavailable' }, 404) : jsonResponse(answers.stats);
+ }
+ if (p.endsWith('/api/rundown/')) {
+ return answers.rundown == null ? jsonResponse({ error: 'unavailable' }, 404) : jsonResponse(answers.rundown);
+ }
+ if (p.endsWith('/api/rundown/topics')) {
+ return answers.topics == null ? jsonResponse({ error: 'unavailable' }, 404) : jsonResponse(answers.topics);
+ }
+ let m = /^\/api\/rundown\/t\/([^/]+)$/.exec(p);
+ if (m) return answers.topic == null ? jsonResponse({ error: 'not found' }, 404) : jsonResponse(answers.topic);
+ m = /^\/api\/rundown\/e\/(\d+)$/.exec(p);
+ if (m) return answers.episode == null ? jsonResponse({ error: 'not found' }, 404) : jsonResponse(answers.episode);
+ m = /^\/api\/rundown\/s\/([^/]+)$/.exec(p);
+ if (m) return answers.speaker == null ? jsonResponse({ error: 'not found' }, 404) : jsonResponse(answers.speaker);
+ m = /^\/api\/recap\/channel\/([^/]+)$/.exec(p);
+ if (m) return answers.channel == null ? jsonResponse({ error: 'not found' }, 404) : jsonResponse(answers.channel);
+ m = /^\/api\/recap\/(\d+)$/.exec(p);
+ if (m) return answers.recap == null ? jsonResponse({ error: 'not found' }, 404) : jsonResponse(answers.recap);
+
+ return jsonResponse({ error: 'no stub for ' + p }, 404);
+ };
+ fetchImpl.calls = calls;
+ return fetchImpl;
+}
+
+async function boot(opts = {}) {
+ const captured = [];
+ const log = {
+ log: (...a) => captured.push(a.map(String).join(' ')),
+ info: (...a) => captured.push(a.map(String).join(' ')),
+ warn: (...a) => captured.push(a.map(String).join(' ')),
+ error: (...a) => captured.push(a.map(String).join(' ')),
+ };
+ const fetchImpl = opts.fetchImpl || stubFetch(opts.data, opts.stub);
+ const env = {
+ NODE_ENV: 'test',
+ PORT: '0',
+ BASE_URL: 'https://openvibe.report',
+ TRUST_PROXY: '1',
+ OV_NETWORK_INTERNAL_URL: NETWORK,
+ OV_LIVE_INTERNAL_URL: LIVE,
+ REPORT_API_RATE_LIMIT_PER_MIN: '100000',
+ ...(opts.env || {}),
+ };
+ const config = configLib.load(env);
+ const built = await createApp({
+ config,
+ fetchImpl,
+ log,
+ now: opts.now,
+ releaseEnv: { ...process.env, RELEASE_COMMIT: '0000000' },
+ });
+ 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}`;
+
+ async function get(pathname, o = {}) {
+ const res = await fetch(base + pathname, {
+ method: o.method || 'GET',
+ headers: o.headers || {},
+ redirect: 'manual',
+ });
+ const text = await res.text();
+ return {
+ status: res.status,
+ headers: res.headers,
+ text,
+ json() { return JSON.parse(text); },
+ };
+ }
+
+ return {
+ base, config, ctx: built.ctx, fetchImpl, get,
+ logs: () => captured.join('\n'),
+ async close() {
+ built.ctx.metricsStop();
+ await new Promise((resolve) => server.close(resolve));
+ },
+ };
+}
+
+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, stubFetch, jsonResponse, defaults, NETWORK, LIVE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+
+/**
+ * Every page route: 200 with stub data, its own canonical and the two-minute public cache; a bad path parameter is a
+ * 404 without a source call; a source that answers nothing is a 404 (the home leaves that part out); every dynamic value is escaped.
+ */
+const assert = require('assert');
+const { boot, check, done, defaults } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ try {
+ const pages = [
+ ['/', ['OpenVibe.Report', '77,408', 'A lead take', 'Seattle']],
+ ['/stats?days=30', ['Every OpenVibe site', '30 days', '77,408', 'OpenVibe.Live']],
+ ['/rundown', ['The Rundown', 'goosely', 'AI progress', 'A written-up stream']],
+ ['/rundown/t/seattle', ['Seattle', 'A headline', 'A take']],
+ ['/rundown/e/7', ['A written-up stream', 'The logline.', 'A segment']],
+ ['/rundown/s/goosely', ['goosely', 'A written-up stream', 'Seattle']],
+ ['/after-show/7', ['Seattle Today', 'After-show report']],
+ ['/c/goosely', ['goosely', 'Seattle Today', 'A tiny but mighty vibe', 'Grade A']],
+ ];
+ for (const [path, expected] of pages) {
+ await check(`GET ${path}: 200 text/html, public for two minutes, the model in the page`, async () => {
+ const r = await t.get(path);
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type') || '', /^text\/html/);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=120');
+ assert.ok(r.text.startsWith('<!doctype html>'), 'not a whole document');
+ assert.match(r.text, /<meta name="robots" content="index, follow">/);
+ for (const value of expected) assert.ok(r.text.includes(value), `missing ${value}`);
+ });
+ }
+
+ await check('a page names its canonical and mounts the Frame for service report', async () => {
+ const r = await t.get('/rundown/t/seattle');
+ assert.ok(r.text.includes('<link rel="canonical" href="https://openvibe.report/rundown/t/seattle">'));
+ assert.ok(r.text.includes('<div id="navbar-mount"></div>'), 'no navbar mount');
+ assert.ok(r.text.includes('id="ov-footer"'), 'no Shared footer');
+ assert.ok(r.text.includes('"service":"report"'), 'the Frame is not initialised for report');
+ assert.ok(r.text.includes('theme-loader.js'), 'no theme loader');
+ assert.ok(r.text.includes('navbar.js') && r.text.includes('footer.js'), 'no Shared Frame scripts');
+ assert.ok(r.text.includes('OpenVibeFooter.init'), 'no footer init');
+ });
+
+ await check('unknown paths are the 404 view with noindex and the page cache', async () => {
+ const r = await t.get('/not-a-page');
+ assert.strictEqual(r.status, 404);
+ assert.match(r.headers.get('content-type') || '', /^text\/html/);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=120');
+ assert.ok(r.text.includes('<h1>Nothing here</h1>'));
+ assert.match(r.text, /<meta name="robots" content="noindex[^"]*">/);
+ });
+
+ await check('a bad slug, stream id or username is a 404 before the source is called', async () => {
+ const before = t.fetchImpl.calls.length;
+ for (const path of ['/rundown/t/Not_A_Slug', '/rundown/t/' + 'a'.repeat(81), '/rundown/e/0', '/rundown/e/abc', '/rundown/s/bad-name', '/after-show/-1', '/c/bad!']) {
+ const r = await t.get(path);
+ assert.strictEqual(r.status, 404, path);
+ }
+ const after = t.fetchImpl.calls.slice(before);
+ assert.strictEqual(after.length, 0, `a bad parameter reached the source: ${after.join(', ')}`);
+ });
+
+ await check('days other than 7, 30 or 90 render the 30-day page', async () => {
+ const r = await t.get('/stats?days=365');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('aria-current="page">30 days'), 'the 30-day tab is the open one');
+ const request = t.fetchImpl.calls.filter((u) => u.includes('/api/v1/stats')).pop();
+ assert.ok(request.endsWith('?days=30'), request);
+ });
+ } finally {
+ await t.close();
+ }
+
+ await check('a source that answers nothing makes the page a 404', async () => {
+ const empty = await boot({ data: { rundown: null, topic: null } });
+ try {
+ assert.strictEqual((await empty.get('/rundown')).status, 404);
+ assert.strictEqual((await empty.get('/rundown/t/seattle')).status, 404);
+ // The home stands on its parts: with the Rundown down it still answers, without that part.
+ const home = await empty.get('/');
+ assert.strictEqual(home.status, 200);
+ assert.ok(!home.text.includes('What streamers talked about') || home.text.includes('rp-topics'), 'no empty Rundown part');
+ assert.strictEqual((await empty.get('/stats')).status, 200, 'an unrelated page still serves');
+ } finally { await empty.close(); }
+ });
+
+ await check('every dynamic value is escaped', async () => {
+ const data = defaults();
+ data.topic.topic.title = '<script>alert(1)</script>';
+ data.topic.topic.headline = '"quoted" & <b>bold</b>';
+ const xss = await boot({ data });
+ try {
+ const r = await xss.get('/rundown/t/seattle');
+ assert.strictEqual(r.status, 200);
+ assert.ok(!r.text.includes('<script>alert(1)</script>'), 'an unescaped script reached the page');
+ assert.ok(r.text.includes('<script>alert(1)</script>'));
+ assert.ok(r.text.includes('"quoted" & <b>bold</b>'));
+ } finally { await xss.close(); }
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
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 stub both source APIs, so no test needs
+ * the network or a running OpenVibe site.
+ *
+ * npm test # everything
+ * npm test -- stats # 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: 60000, 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); });