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

OP OpenVibers committed 16 days ago · 7c4be2636f · 34 files, +4242 −0


Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..3138d2d
---- /dev/null
++++ b/.env.example
@@@ -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=
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..c805bd2
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -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
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..d7f49d6
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,11 @@
++node_modules/
++.npm-cache/
++.ref/
++.env
++.env.*
++!.env.example
++*.log
++*.pem
++*.key
++coverage/
++.DS_Store
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
nnew file mode 100644
iindex 0000000..ec75ed0
---- /dev/null
++++ b/CHANGELOG.md
@@@ -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.
ddiff --git a/LICENSE b/LICENSE
nnew file mode 100644
iindex 0000000..0ad25db
---- /dev/null
++++ b/LICENSE
@@@ -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/>.
ddiff --git a/README.md b/README.md
nnew file mode 100644
iindex 0000000..c484a1c
---- /dev/null
++++ b/README.md
@@@ -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).
ddiff --git a/SECURITY.md b/SECURITY.md
nnew file mode 100644
iindex 0000000..7cde8b1
---- /dev/null
++++ b/SECURITY.md
@@@ -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.
ddiff --git a/STATUS.json b/STATUS.json
nnew file mode 100644
iindex 0000000..da9b3c9
---- /dev/null
++++ b/STATUS.json
@@@ -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"
++}
ddiff --git a/deploy/nginx/openvibe.report.conf b/deploy/nginx/openvibe.report.conf
nnew file mode 100644
iindex 0000000..5cb3103
---- /dev/null
++++ b/deploy/nginx/openvibe.report.conf
@@@ -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;
++ }
++}
ddiff --git a/deploy/systemd/openvibe-report.service b/deploy/systemd/openvibe-report.service
nnew file mode 100644
iindex 0000000..f5eb61f
---- /dev/null
++++ b/deploy/systemd/openvibe-report.service
@@@ -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
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..12198c4
---- /dev/null
++++ b/package-lock.json
@@@ -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"
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..b5804e9
---- /dev/null
++++ b/package.json
@@@ -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"
++ }
++}
ddiff --git a/public/css/app.css b/public/css/app.css
nnew file mode 100644
iindex 0000000..9e12c74
---- /dev/null
++++ b/public/css/app.css
@@@ -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; } }
ddiff --git a/public/js/app.js b/public/js/app.js
nnew file mode 100644
iindex 0000000..1163dd8
---- /dev/null
++++ b/public/js/app.js
@@@ -0,0 +1,2 @@
++'use strict';
++/* OpenVibe.Report — placeholder script. The real pages add their own in-browser conveniences. */
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..231be92
---- /dev/null
++++ b/server/app.js
@@@ -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 };
ddiff --git a/server/assets.js b/server/assets.js
nnew file mode 100644
iindex 0000000..1f891c4
---- /dev/null
++++ b/server/assets.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..00bb32a
---- /dev/null
++++ b/server/config.js
@@@ -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 };
ddiff --git a/server/data/cache.js b/server/data/cache.js
nnew file mode 100644
iindex 0000000..06ebd9c
---- /dev/null
++++ b/server/data/cache.js
@@@ -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 };
ddiff --git a/server/data/live.js b/server/data/live.js
nnew file mode 100644
iindex 0000000..96878c3
---- /dev/null
++++ b/server/data/live.js
@@@ -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 };
ddiff --git a/server/data/network.js b/server/data/network.js
nnew file mode 100644
iindex 0000000..6bfdda2
---- /dev/null
++++ b/server/data/network.js
@@@ -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 };
ddiff --git a/server/data/source.js b/server/data/source.js
nnew file mode 100644
iindex 0000000..e8591c2
---- /dev/null
++++ b/server/data/source.js
@@@ -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 };
ddiff --git a/server/http/api.js b/server/http/api.js
nnew file mode 100644
iindex 0000000..4b5854f
---- /dev/null
++++ b/server/http/api.js
@@@ -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 };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
nnew file mode 100644
iindex 0000000..f3ea951
---- /dev/null
++++ b/server/http/discovery.js
@@@ -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 };
ddiff --git a/server/http/pages.js b/server/http/pages.js
nnew file mode 100644
iindex 0000000..70fe730
---- /dev/null
++++ b/server/http/pages.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..06dfa77
---- /dev/null
++++ b/server/index.js
@@@ -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 };
ddiff --git a/server/observability.js b/server/observability.js
nnew file mode 100644
iindex 0000000..2180c41
---- /dev/null
++++ b/server/observability.js
@@@ -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 };
ddiff --git a/server/views/index.js b/server/views/index.js
nnew file mode 100644
iindex 0000000..0750a28
---- /dev/null
++++ b/server/views/index.js
@@@ -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 };
ddiff --git a/test/api.test.js b/test/api.test.js
nnew file mode 100644
iindex 0000000..99e83b8
---- /dev/null
++++ b/test/api.test.js
@@@ -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); });
ddiff --git a/test/data.test.js b/test/data.test.js
nnew file mode 100644
iindex 0000000..a716f07
---- /dev/null
++++ b/test/data.test.js
@@@ -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); });
ddiff --git a/test/discovery.test.js b/test/discovery.test.js
nnew file mode 100644
iindex 0000000..da66709
---- /dev/null
++++ b/test/discovery.test.js
@@@ -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); });
ddiff --git a/test/health.test.js b/test/health.test.js
nnew file mode 100644
iindex 0000000..a13ff80
---- /dev/null
++++ b/test/health.test.js
@@@ -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); });
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
nnew file mode 100644
iindex 0000000..04607f0
---- /dev/null
++++ b/test/helpers/boot.js
@@@ -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 };
ddiff --git a/test/pages.test.js b/test/pages.test.js
nnew file mode 100644
iindex 0000000..ebe05bf
---- /dev/null
++++ b/test/pages.test.js
@@@ -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('&lt;script&gt;alert(1)&lt;/script&gt;'));
++ assert.ok(r.text.includes('&quot;quoted&quot; &amp; &lt;b&gt;bold&lt;/b&gt;'));
++ } finally { await xss.close(); }
++ });
++
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..975a03a
---- /dev/null
++++ b/test/run.js
@@@ -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); });