openvibers/openvibe.bench Commits
OpenVibe.Bench v1
Changes
nnew file mode 100644@@@ -0,0 +1,43 @@++# OpenVibe.Bench — production: /etc/openvibe/bench.env. Only variable NAMES appear in code and docs.++NODE_ENV=production++PORT=5230++HOST=127.0.0.1++BASE_URL=https://bench.openvibe.services++TRUST_PROXY=2++++# PostgreSQL (ADR-035). DATABASE_URL serves, DATABASE_DIRECT_URL migrates (owner role).++DATABASE_URL=++DATABASE_DIRECT_URL=++# Development fallback (no DATABASE_URL): embedded PGlite.++BENCH_PGLITE_DIR=++++# OpenVibe.Network SSO/JWKS.++OV_NETWORK_URL=https://openvibe.network++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++OV_NETWORK_ISSUER=https://openvibe.network++OV_OAUTH_CLIENT_ID=bench++OV_OAUTH_CLIENT_SECRET=++OV_OAUTH_REDIRECT_URI=https://bench.openvibe.services/auth/callback++OV_SESSION_AUDIENCE=openvibe.network++BENCH_AUDIENCE=openvibe.bench++COOKIE_SECURE=true++++# OpenVibe.AI (client credentials, audience openvibe.ai, scope ai.run.create; Network grants ai.generate ns).++OV_AI_INTERNAL_URL=http://127.0.0.1:4700++BENCH_AI_AUDIENCE=openvibe.ai++BENCH_AI_SCOPE=ai.run.create++BENCH_MODELS=++++# Bench budgets.++BENCH_ROUNDS_PER_DAY=3++BENCH_ROUNDS_DAILY=20++BENCH_EVALS_PER_DAY=20++++# OpenVibe.Events outbox and account export/deletion.++BENCH_EVENTS_URL=++BENCH_EVENTS_SECRET=++BENCH_EVENTS_SCOPE=events.event.publish++++# Optional Valkey for shared rate counters.++VALKEY_URL=++VALKEY_PREFIX=ov:bench:nnew file mode 100644@@@ -0,0 +1,18 @@++name: CI++++on:++ push:++ branches: [main]++ pull_request:++++jobs:++ test:++ runs-on: ubuntu-latest++ steps:++ - uses: actions/checkout@v4++ - uses: actions/setup-node@v4++ with:++ node-version: 22++ cache: npm++ - run: npm install++ - run: OV_ALLOW_SUITE=1 npm testnnew file mode 100644@@@ -0,0 +1,11 @@++node_modules/++data/++dist/++.env++.env.*++!.env.example++.npm-cache/++*.log++*.pem++*.key++!server/data/nnew file mode 100644@@@ -0,0 +1,8 @@++# Changelog++++## 0.1.0 — 2026-10-28++++- OpenVibe.Bench v1: the shared AI benchmark and comparison engine (plan 2026-10-28 §4.12).++- Fifteen kinds seeded from code; pinned, reproducible rounds; blind pairwise judgements with one vote per person per round and no vote on your own round.++- Elo, pareto and cost boards; open dataset per kind without voter ids; private evals (free daily cap, no charging in v1).++- MCP endpoint (`POST /mcp`) and events outbox (`bench.round.finished`, `bench.judgement.recorded`, `bench.rating.changed`).nnew file mode 100644@@@ -0,0 +1,661 @@++ GNU AFFERO GENERAL PUBLIC LICENSE++ Version 3, 19 November 2007++++ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>++ Everyone is permitted to copy and distribute verbatim copies++ of this license document, but changing it is not allowed.++++ Preamble++++ The GNU Affero General Public License is a free, copyleft license for++software and other kinds of works, specifically designed to ensure++cooperation with the community in the case of network server software.++++ The licenses for most software and other practical works are designed++to take away your freedom to share and change the works. By contrast,++our General Public Licenses are intended to guarantee your freedom to++share and change all versions of a program--to make sure it remains free++software for all its users.++++ When we speak of free software, we are referring to freedom, not++price. Our General Public Licenses are designed to make sure that you++have the freedom to distribute copies of free software (and charge for++them if you wish), that you receive source code or can get it if you++want it, that you can change the software or use pieces of it in new++free programs, and that you know you can do these things.++++ Developers that use our General Public Licenses protect your rights++with two steps: (1) assert copyright on the software, and (2) offer++you this License which gives you legal permission to copy, distribute++and/or modify the software.++++ A secondary benefit of defending all users' freedom is that++improvements made in alternate versions of the program, if they++receive widespread use, become available for other developers to++incorporate. Many developers of free software are heartened and++encouraged by the resulting cooperation. However, in the case of++software used on network servers, this result may fail to come about.++The GNU General Public License permits making a modified version and++letting the public access it on a server without ever releasing its++source code to the public.++++ The GNU Affero General Public License is designed specifically to++ensure that, in such cases, the modified source code becomes available++to the community. It requires the operator of a network server to++provide the source code of the modified version running there to the++users of that server. Therefore, public use of a modified version, on++a publicly accessible server, gives the public access to the source++code of the modified version.++++ An older license, called the Affero General Public License and++published by Affero, was designed to accomplish similar goals. This is++a different license, not a version of the Affero GPL, but Affero has++released a new version of the Affero GPL which permits relicensing under++this license.++++ The precise terms and conditions for copying, distribution and++modification follow.++++ TERMS AND CONDITIONS++++ 0. Definitions.++++ "This License" refers to version 3 of the GNU Affero General Public License.++++ "Copyright" also means copyright-like laws that apply to other kinds of++works, such as semiconductor masks.++++ "The Program" refers to any copyrightable work licensed under this++License. Each licensee is addressed as "you". "Licensees" and++"recipients" may be individuals or organizations.++++ To "modify" a work means to copy from or adapt all or part of the work++in a fashion requiring copyright permission, other than the making of an++exact copy. The resulting work is called a "modified version" of the++earlier work or a work "based on" the earlier work.++++ A "covered work" means either the unmodified Program or a work based++on the Program.++++ To "propagate" a work means to do anything with it that, without++permission, would make you directly or secondarily liable for++infringement under applicable copyright law, except executing it on a++computer or modifying a private copy. Propagation includes copying,++distribution (with or without modification), making available to the++public, and in some countries other activities as well.++++ To "convey" a work means any kind of propagation that enables other++parties to make or receive copies. Mere interaction with a user through++a computer network, with no transfer of a copy, is not conveying.++++ An interactive user interface displays "Appropriate Legal Notices"++to the extent that it includes a convenient and prominently visible++feature that (1) displays an appropriate copyright notice, and (2)++tells the user that there is no warranty for the work (except to the++extent that warranties are provided), that licensees may convey the++work under this License, and how to view a copy of this License. If++the interface presents a list of user commands or options, such as a++menu, a prominent item in the list meets this criterion.++++ 1. Source Code.++++ The "source code" for a work means the preferred form of the work++for making modifications to it. "Object code" means any non-source++form of a work.++++ A "Standard Interface" means an interface that either is an official++standard defined by a recognized standards body, or, in the case of++interfaces specified for a particular programming language, one that++is widely used among developers working in that language.++++ The "System Libraries" of an executable work include anything, other++than the work as a whole, that (a) is included in the normal form of++packaging a Major Component, but which is not part of that Major++Component, and (b) serves only to enable use of the work with that++Major Component, or to implement a Standard Interface for which an++implementation is available to the public in source code form. A++"Major Component", in this context, means a major essential component++(kernel, window system, and so on) of the specific operating system++(if any) on which the executable work runs, or a compiler used to++produce the work, or an object code interpreter used to run it.++++ The "Corresponding Source" for a work in object code form means all++the source code needed to generate, install, and (for an executable++work) run the object code and to modify the work, including scripts to++control those activities. However, it does not include the work's++System Libraries, or general-purpose tools or generally available free++programs which are used unmodified in performing those activities but++which are not part of the work. For example, Corresponding Source++includes interface definition files associated with source files for++the work, and the source code for shared libraries and dynamically++linked subprograms that the work is specifically designed to require,++such as by intimate data communication or control flow between those++subprograms and other parts of the work.++++ The Corresponding Source need not include anything that users++can regenerate automatically from other parts of the Corresponding++Source.++++ The Corresponding Source for a work in source code form is that++same work.++++ 2. Basic Permissions.++++ All rights granted under this License are granted for the term of++copyright on the Program, and are irrevocable provided the stated++conditions are met. This License explicitly affirms your unlimited++permission to run the unmodified Program. The output from running a++covered work is covered by this License only if the output, given its++content, constitutes a covered work. This License acknowledges your++rights of fair use or other equivalent, as provided by copyright law.++++ You may make, run and propagate covered works that you do not++convey, without conditions so long as your license otherwise remains++in force. You may convey covered works to others for the sole purpose++of having them make modifications exclusively for you, or provide you++with facilities for running those works, provided that you comply with++the terms of this License in conveying all material for which you do++not control copyright. Those thus making or running the covered works++for you must do so exclusively on your behalf, under your direction++and control, on terms that prohibit them from making any copies of++your copyrighted material outside their relationship with you.++++ Conveying under any other circumstances is permitted solely under++the conditions stated below. Sublicensing is not allowed; section 10++makes it unnecessary.++++ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.++++ No covered work shall be deemed part of an effective technological++measure under any applicable law fulfilling obligations under article++11 of the WIPO copyright treaty adopted on 20 December 1996, or++similar laws prohibiting or restricting circumvention of such++measures.++++ When you convey a covered work, you waive any legal power to forbid++circumvention of technological measures to the extent such circumvention++is effected by exercising rights under this License with respect to++the covered work, and you disclaim any intention to limit operation or++modification of the work as a means of enforcing, against the work's++users, your or third parties' legal rights to forbid circumvention of++technological measures.++++ 4. Conveying Verbatim Copies.++++ You may convey verbatim copies of the Program's source code as you++receive it, in any medium, provided that you conspicuously and++appropriately publish on each copy an appropriate copyright notice;++keep intact all notices stating that this License and any++non-permissive terms added in accord with section 7 apply to the code;++keep intact all notices of the absence of any warranty; and give all++recipients a copy of this License along with the Program.++++ You may charge any price or no price for each copy that you convey,++and you may offer support or warranty protection for a fee.++++ 5. Conveying Modified Source Versions.++++ You may convey a work based on the Program, or the modifications to++produce it from the Program, in the form of source code under the++terms of section 4, provided that you also meet all of these conditions:++++ a) The work must carry prominent notices stating that you modified++ it, and giving a relevant date.++++ b) The work must carry prominent notices stating that it is++ released under this License and any conditions added under section++ 7. This requirement modifies the requirement in section 4 to++ "keep intact all notices".++++ c) You must license the entire work, as a whole, under this++ License to anyone who comes into possession of a copy. This++ License will therefore apply, along with any applicable section 7++ additional terms, to the whole of the work, and all its parts,++ regardless of how they are packaged. This License gives no++ permission to license the work in any other way, but it does not++ invalidate such permission if you have separately received it.++++ d) If the work has interactive user interfaces, each must display++ Appropriate Legal Notices; however, if the Program has interactive++ interfaces that do not display Appropriate Legal Notices, your++ work need not make them do so.++++ A compilation of a covered work with other separate and independent++works, which are not by their nature extensions of the covered work,++and which are not combined with it such as to form a larger program,++in or on a volume of a storage or distribution medium, is called an++"aggregate" if the compilation and its resulting copyright are not++used to limit the access or legal rights of the compilation's users++beyond what the individual works permit. Inclusion of a covered work++in an aggregate does not cause this License to apply to the other++parts of the aggregate.++++ 6. Conveying Non-Source Forms.++++ You may convey a covered work in object code form under the terms++of sections 4 and 5, provided that you also convey the++machine-readable Corresponding Source under the terms of this License,++in one of these ways:++++ a) Convey the object code in, or embodied in, a physical product++ (including a physical distribution medium), accompanied by the++ Corresponding Source fixed on a durable physical medium++ customarily used for software interchange.++++ b) Convey the object code in, or embodied in, a physical product++ (including a physical distribution medium), accompanied by a++ written offer, valid for at least three years and valid for as++ long as you offer spare parts or customer support for that product++ model, to give anyone who possesses the object code either (1) a++ copy of the Corresponding Source for all the software in the++ product that is covered by this License, on a durable physical++ medium customarily used for software interchange, for a price no++ more than your reasonable cost of physically performing this++ conveying of source, or (2) access to copy the++ Corresponding Source from a network server at no charge.++++ c) Convey individual copies of the object code with a copy of the++ written offer to provide the Corresponding Source. This++ alternative is allowed only occasionally and noncommercially, and++ only if you received the object code with such an offer, in accord++ with subsection 6b.++++ d) Convey the object code by offering access from a designated++ place (gratis or for a charge), and offer equivalent access to the++ Corresponding Source in the same way through the same place at no++ further charge. You need not require recipients to copy the++ Corresponding Source along with the object code. If the place to++ copy the object code is a network server, the Corresponding Source++ may be on a different server (operated by you or a third party)++ that supports equivalent copying facilities, provided you maintain++ clear directions next to the object code saying where to find the++ Corresponding Source. Regardless of what server hosts the++ Corresponding Source, you remain obligated to ensure that it is++ available for as long as needed to satisfy these requirements.++++ e) Convey the object code using peer-to-peer transmission, provided++ you inform other peers where the object code and Corresponding++ Source of the work are being offered to the general public at no++ charge under subsection 6d.++++ A separable portion of the object code, whose source code is excluded++from the Corresponding Source as a System Library, need not be++included in conveying the object code work.++++ A "User Product" is either (1) a "consumer product", which means any++tangible personal property which is normally used for personal, family,++or household purposes, or (2) anything designed or sold for incorporation++into a dwelling. In determining whether a product is a consumer product,++doubtful cases shall be resolved in favor of coverage. For a particular++product received by a particular user, "normally used" refers to a++typical or common use of that class of product, regardless of the status++of the particular user or of the way in which the particular user++actually uses, or expects or is expected to use, the product. A product++is a consumer product regardless of whether the product has substantial++commercial, industrial or non-consumer uses, unless such uses represent++the only significant mode of use of the product.++++ "Installation Information" for a User Product means any methods,++procedures, authorization keys, or other information required to install++and execute modified versions of a covered work in that User Product from++a modified version of its Corresponding Source. The information must++suffice to ensure that the continued functioning of the modified object++code is in no case prevented or interfered with solely because++modification has been made.++++ If you convey an object code work under this section in, or with, or++specifically for use in, a User Product, and the conveying occurs as++part of a transaction in which the right of possession and use of the++User Product is transferred to the recipient in perpetuity or for a++fixed term (regardless of how the transaction is characterized), the++Corresponding Source conveyed under this section must be accompanied++by the Installation Information. But this requirement does not apply++if neither you nor any third party retains the ability to install++modified object code on the User Product (for example, the work has++been installed in ROM).++++ The requirement to provide Installation Information does not include a++requirement to continue to provide support service, warranty, or updates++for a work that has been modified or installed by the recipient, or for++the User Product in which it has been modified or installed. Access to a++network may be denied when the modification itself materially and++adversely affects the operation of the network or violates the rules and++protocols for communication across the network.++++ Corresponding Source conveyed, and Installation Information provided,++in accord with this section must be in a format that is publicly++documented (and with an implementation available to the public in++source code form), and must require no special password or key for++unpacking, reading or copying.++++ 7. Additional Terms.++++ "Additional permissions" are terms that supplement the terms of this++License by making exceptions from one or more of its conditions.++Additional permissions that are applicable to the entire Program shall++be treated as though they were included in this License, to the extent++that they are valid under applicable law. If additional permissions++apply only to part of the Program, that part may be used separately++under those permissions, but the entire Program remains governed by++this License without regard to the additional permissions.++++ When you convey a copy of a covered work, you may at your option++remove any additional permissions from that copy, or from any part of++it. (Additional permissions may be written to require their own++removal in certain cases when you modify the work.) You may place++additional permissions on material, added by you to a covered work,++for which you have or can give appropriate copyright permission.++++ Notwithstanding any other provision of this License, for material you++add to a covered work, you may (if authorized by the copyright holders of++that material) supplement the terms of this License with terms:++++ a) Disclaiming warranty or limiting liability differently from the++ terms of sections 15 and 16 of this License; or++++ b) Requiring preservation of specified reasonable legal notices or++ author attributions in that material or in the Appropriate Legal++ Notices displayed by works containing it; or++++ c) Prohibiting misrepresentation of the origin of that material, or++ requiring that modified versions of such material be marked in++ reasonable ways as different from the original version; or++++ d) Limiting the use for publicity purposes of names of licensors or++ authors of the material; or++++ e) Declining to grant rights under trademark law for use of some++ trade names, trademarks, or service marks; or++++ f) Requiring indemnification of licensors and authors of that++ material by anyone who conveys the material (or modified versions of++ it) with contractual assumptions of liability to the recipient, for++ any liability that these contractual assumptions directly impose on++ those licensors and authors.++++ All other non-permissive additional terms are considered "further++restrictions" within the meaning of section 10. If the Program as you++received it, or any part of it, contains a notice stating that it is++governed by this License along with a term that is a further++restriction, you may remove that term. If a license document contains++a further restriction but permits relicensing or conveying under this++License, you may add to a covered work material governed by the terms++of that license document, provided that the further restriction does++not survive such relicensing or conveying.++++ If you add terms to a covered work in accord with this section, you++must place, in the relevant source files, a statement of the++additional terms that apply to those files, or a notice indicating++where to find the applicable terms.++++ Additional terms, permissive or non-permissive, may be stated in the++form of a separately written license, or stated as exceptions;++the above requirements apply either way.++++ 8. Termination.++++ You may not propagate or modify a covered work except as expressly++provided under this License. Any attempt otherwise to propagate or++modify it is void, and will automatically terminate your rights under++this License (including any patent licenses granted under the third++paragraph of section 11).++++ However, if you cease all violation of this License, then your++license from a particular copyright holder is reinstated (a)++provisionally, unless and until the copyright holder explicitly and++finally terminates your license, and (b) permanently, if the copyright++holder fails to notify you of the violation by some reasonable means++prior to 60 days after the cessation.++++ Moreover, your license from a particular copyright holder is++reinstated permanently if the copyright holder notifies you of the++violation by some reasonable means, this is the first time you have++received notice of violation of this License (for any work) from that++copyright holder, and you cure the violation prior to 30 days after++your receipt of the notice.++++ Termination of your rights under this section does not terminate the++licenses of parties who have received copies or rights from you under++this License. If your rights have been terminated and not permanently++reinstated, you do not qualify to receive new licenses for the same++material under section 10.++++ 9. Acceptance Not Required for Having Copies.++++ You are not required to accept this License in order to receive or++run a copy of the Program. Ancillary propagation of a covered work++occurring solely as a consequence of using peer-to-peer transmission++to receive a copy likewise does not require acceptance. However,++nothing other than this License grants you permission to propagate or++modify any covered work. These actions infringe copyright if you do++not accept this License. Therefore, by modifying or propagating a++covered work, you indicate your acceptance of this License to do so.++++ 10. Automatic Licensing of Downstream Recipients.++++ Each time you convey a covered work, the recipient automatically++receives a license from the original licensors, to run, modify and++propagate that work, subject to this License. You are not responsible++for enforcing compliance by third parties with this License.++++ An "entity transaction" is a transaction transferring control of an++organization, or substantially all assets of one, or subdividing an++organization, or merging organizations. If propagation of a covered++work results from an entity transaction, each party to that++transaction who receives a copy of the work also receives whatever++licenses to the work the party's predecessor in interest had or could++give under the previous paragraph, plus a right to possession of the++Corresponding Source of the work from the predecessor in interest, if++the predecessor has it or can get it with reasonable efforts.++++ You may not impose any further restrictions on the exercise of the++rights granted or affirmed under this License. For example, you may++not impose a license fee, royalty, or other charge for exercise of++rights granted under this License, and you may not initiate litigation++(including a cross-claim or counterclaim in a lawsuit) alleging that++any patent claim is infringed by making, using, selling, offering for++sale, or importing the Program or any portion of it.++++ 11. Patents.++++ A "contributor" is a copyright holder who authorizes use under this++License of the Program or a work on which the Program is based. The++work thus licensed is called the contributor's "contributor version".++++ A contributor's "essential patent claims" are all patent claims++owned or controlled by the contributor, whether already acquired or++hereafter acquired, that would be infringed by some manner, permitted++by this License, of making, using, or selling its contributor version,++but do not include claims that would be infringed only as a++consequence of further modification of the contributor version. For++purposes of this definition, "control" includes the right to grant++patent sublicenses in a manner consistent with the requirements of++this License.++++ Each contributor grants you a non-exclusive, worldwide, royalty-free++patent license under the contributor's essential patent claims, to++make, use, sell, offer for sale, import and otherwise run, modify and++propagate the contents of its contributor version.++++ In the following three paragraphs, a "patent license" is any express++agreement or commitment, however denominated, not to enforce a patent++(such as an express permission to practice a patent or covenant not to++sue for patent infringement). To "grant" such a patent license to a++party means to make such an agreement or commitment not to enforce a++patent against the party.++++ If you convey a covered work, knowingly relying on a patent license,++and the Corresponding Source of the work is not available for anyone++to copy, free of charge and under the terms of this License, through a++publicly available network server or other readily accessible means,++then you must either (1) cause the Corresponding Source to be so++available, or (2) arrange to deprive yourself of the benefit of the++patent license for this particular work, or (3) arrange, in a manner++consistent with the requirements of this License, to extend the patent++license to downstream recipients. "Knowingly relying" means you have++actual knowledge that, but for the patent license, your conveying the++covered work in a country, or your recipient's use of the covered work++in a country, would infringe one or more identifiable patents in that++country that you have reason to believe are valid.++++ If, pursuant to or in connection with a single transaction or++arrangement, you convey, or propagate by procuring conveyance of, a++covered work, and grant a patent license to some of the parties++receiving the covered work authorizing them to use, propagate, modify++or convey a specific copy of the covered work, then the patent license++you grant is automatically extended to all recipients of the covered++work and works based on it.++++ A patent license is "discriminatory" if it does not include within++the scope of its coverage, prohibits the exercise of, or is++conditioned on the non-exercise of one or more of the rights that are++specifically granted under this License. You may not convey a covered++work if you are a party to an arrangement with a third party that is++in the business of distributing software, under which you make payment++to the third party based on the extent of your activity of conveying++the work, and under which the third party grants, to any of the++parties who would receive the covered work from you, a discriminatory++patent license (a) in connection with copies of the covered work++conveyed by you (or copies made from those copies), or (b) primarily++for and in connection with specific products or compilations that++contain the covered work, unless you entered into that arrangement,++or that patent license was granted, prior to 28 March 2007.++++ Nothing in this License shall be construed as excluding or limiting++any implied license or other defenses to infringement that may++otherwise be available to you under applicable patent law.++++ 12. No Surrender of Others' Freedom.++++ If conditions are imposed on you (whether by court order, agreement or++otherwise) that contradict the conditions of this License, they do not++excuse you from the conditions of this License. If you cannot convey a++covered work so as to satisfy simultaneously your obligations under this++License and any other pertinent obligations, then as a consequence you may++not convey it at all. For example, if you agree to terms that obligate you++to collect a royalty for further conveying from those to whom you convey++the Program, the only way you could satisfy both those terms and this++License would be to refrain entirely from conveying the Program.++++ 13. Remote Network Interaction; Use with the GNU General Public License.++++ Notwithstanding any other provision of this License, if you modify the++Program, your modified version must prominently offer all users++interacting with it remotely through a computer network (if your version++supports such interaction) an opportunity to receive the Corresponding++Source of your version by providing access to the Corresponding Source++from a network server at no charge, through some standard or customary++means of facilitating copying of software. This Corresponding Source++shall include the Corresponding Source for any work covered by version 3++of the GNU General Public License that is incorporated pursuant to the++following paragraph.++++ Notwithstanding any other provision of this License, you have++permission to link or combine any covered work with a work licensed++under version 3 of the GNU General Public License into a single++combined work, and to convey the resulting work. The terms of this++License will continue to apply to the part which is the covered work,++but the work with which it is combined will remain governed by version++3 of the GNU General Public License.++++ 14. Revised Versions of this License.++++ The Free Software Foundation may publish revised and/or new versions of++the GNU Affero General Public License from time to time. Such new versions++will be similar in spirit to the present version, but may differ in detail to++address new problems or concerns.++++ Each version is given a distinguishing version number. If the++Program specifies that a certain numbered version of the GNU Affero General++Public License "or any later version" applies to it, you have the++option of following the terms and conditions either of that numbered++version or of any later version published by the Free Software++Foundation. If the Program does not specify a version number of the++GNU Affero General Public License, you may choose any version ever published++by the Free Software Foundation.++++ If the Program specifies that a proxy can decide which future++versions of the GNU Affero General Public License can be used, that proxy's++public statement of acceptance of a version permanently authorizes you++to choose that version for the Program.++++ Later license versions may give you additional or different++permissions. However, no additional obligations are imposed on any++author or copyright holder as a result of your choosing to follow a++later version.++++ 15. Disclaimer of Warranty.++++ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY++APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT++HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY++OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,++THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR++PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM++IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF++ALL NECESSARY SERVICING, REPAIR OR CORRECTION.++++ 16. Limitation of Liability.++++ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING++WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS++THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY++GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE++USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF++DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD++PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),++EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF++SUCH DAMAGES.++++ 17. Interpretation of Sections 15 and 16.++++ If the disclaimer of warranty and limitation of liability provided++above cannot be given local legal effect according to their terms,++reviewing courts shall apply local law that most closely approximates++an absolute waiver of all civil liability in connection with the++Program, unless a warranty or assumption of liability accompanies a++copy of the Program in return for a fee.++++ END OF TERMS AND CONDITIONS++++ How to Apply These Terms to Your New Programs++++ If you develop a new program, and you want it to be of the greatest++possible use to the public, the best way to achieve this is to make it++free software which everyone can redistribute and change under these terms.++++ To do so, attach the following notices to the program. It is safest++to attach them to the start of each source file to most effectively++state the exclusion of warranty; and each file should have at least++the "copyright" line and a pointer to where the full notice is found.++++ <one line to give the program's name and a brief idea of what it does.>++ Copyright (C) <year> <name of author>++++ This program is free software: you can redistribute it and/or modify++ it under the terms of the GNU Affero General Public License as published++ by the Free Software Foundation, either version 3 of the License, or++ (at your option) any later version.++++ This program is distributed in the hope that it will be useful,++ but WITHOUT ANY WARRANTY; without even the implied warranty of++ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the++ GNU Affero General Public License for more details.++++ You should have received a copy of the GNU Affero General Public License++ along with this program. If not, see <https://www.gnu.org/licenses/>.++++Also add information on how to contact you by electronic and paper mail.++++ If your software can interact with users remotely through a computer++network, you should also make sure that it provides a way for users to++get its source. For example, if your program is a web application, its++interface could display a "Source" link that leads users to an archive++of the code. There are many ways you could offer source, and different++solutions will be better for different programs; see section 13 for the++specific requirements.++++ You should also get your employer (if you work as a programmer) or school,++if any, to sign a "copyright disclaimer" for the program, if necessary.++For more information on this, and how to apply and follow the GNU AGPL, see++<https://www.gnu.org/licenses/>.nnew file mode 100644@@@ -0,0 +1,32 @@++# OpenVibe.Bench++++The shared AI benchmark and comparison engine for every OpenVibe room (plan 2026-10-28 §4.12).++++Bench is the comparative-evaluation authority. AI stays the run authority, Report the analytics authority, Route the learner, and each room keeps its own pages. Bench owns kinds, tasks, pinned rounds, entries, judgements, ratings, boards, the open dataset and private evals.++++- Service id: `bench`++- Port: `5230`++- Public origin: `https://bench.openvibe.services`++- Database: `ov_bench`++- Cookies: `bench_at`, `bench_rt`, `bench_oauth`++++## Run++++```++npm install++npm start++```++++Without `DATABASE_URL` the service runs an embedded PGlite database in `data/pglite` (development only). Production uses PostgreSQL with `DATABASE_DIRECT_URL` for migrations.++++## Test++++```++OV_ALLOW_SUITE=1 npm test++```++++Tests use a temporary PGlite database, an in-process mock Network and a fake AI; none needs the network or a paid API.++++## API++++JSON under `/api/v1`; errors are `application/problem+json`. See `/docs` for the full list. The MCP endpoint is `POST /mcp`.nnew file mode 100644@@@ -0,0 +1,38 @@++# 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.meme, openvibe.today, openvibe.tools,++openvibe.media, openvibe.community, openvibe.games and the other openvibe.* domains) and every repository under++github.com/OpenVibers. Machine-readable contact details are at `/.well-known/security.txt` on each site.++++## What OpenVibe.Meme holds++++- Public entries: the title, kind, AI-written summary, status, times and revision. An entry is the network's record,++ not a person's row, so it is not exported or erased with an account.++- Public citations: a verbatim quote from public speech, its sha256, the canonical public URL and the source ids++ (chat message id, VOD/clip id, Rundown segment id). No private VOD bytes, thumbnails or metadata are kept.++- A person's weekly votes, saved caption lines and reports. These are exported and erased with an account.++- Network session tokens only in httpOnly cookies (`meme_at`, `meme_rt`); no token is stored in the database,++ rendered into a page or logged.++++VOD 5709 and clip 483 are a fixed deny list: no title, thumbnail or quote from either is ever stored, and a++citation naming them is dropped before it is written. A citation URL is re-read daily; a 404 marks it gone.++++A caption is plain text lines on a template; v1 never accepts, renders or stores an image uploaded by a person.++The `/internal/events` account route is loopback only and refuses a proxied request; nginx answers 404 for++`/internal` and `/metrics`.++++## Supported versions++++Only the current `main` branch, which is what runs in production, receives fixes.nnew file mode 100644@@@ -0,0 +1,8 @@++{++ "service": "bench",++ "name": "OpenVibe.Bench",++ "version": "0.1.0",++ "updated": "2026-10-28",++ "status": "v1",++ "summary": "The shared AI benchmark and comparison engine for every room: kinds, pinned rounds, blind judgements, elo/pareto/cost boards, an open dataset per kind, private evals and an MCP endpoint."++}nnew file mode 100644@@@ -0,0 +1,105 @@++## ═══════════════════════════════════════════════════════════════++## bench.openvibe.services — Nginx Configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/bench.openvibe.services.conf — keep them in sync, and reload++## nginx after changing it. It uses the *.openvibe.services certificate the host already holds++## (/etc/letsencrypt/live/openvibe.services/), the same wildcard AI uses.++##++## The Node service (port 5230, systemd unit openvibe-bench) serves:++## / the product home for a browser++## /docs the Bench API docs (server-rendered, no sign-in)++## /shared/* the home's OpenVibe Frame files; /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt++## /api/health, /api/ready++## /auth/* Network sign-in (redirect URI https://bench.openvibe.services/auth/callback, OAuth++## client `bench`)++## /mcp the Bench MCP endpoint (POST)++## /api/v1/* the service API: service-to-service with first-party service tokens. Services on this++## host call 127.0.0.1:5230 directly, so the public vhost refuses it.++## /metrics Prometheus text, answered only to direct loopback callers (never proxied)++## ═══════════════════════════════════════════════════════════════++++server {++ listen 80;++ listen [::]:80;++ server_name bench.openvibe.services;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://bench.openvibe.services$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name bench.openvibe.services;++++ ssl_certificate /etc/letsencrypt/live/openvibe.services/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.services/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/bench.openvibe.services.access.log;++ error_log /var/log/nginx/bench.openvibe.services.error.log;++++ # Task prompts, entry payloads and MCP bodies are the only request bodies Bench accepts.++ client_max_body_size 64k;++++ add_header X-Content-Type-Options nosniff always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++++ location = /api/health { proxy_pass http://127.0.0.1:5230; 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; }++ location = /api/ready { proxy_pass http://127.0.0.1:5230; 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; }++++ # Metrics are for host-local scrapers only (http://127.0.0.1:5230/metrics).++ location ~* ^/metrics(/|$) { return 404; }++++ # Service API: host-local only (see header).++ location /api/ { return 403; }++++ # Network sign-in and the account-data loopback are handled by the app; the app refuses the loopback path.++ location /auth/ {++ proxy_pass http://127.0.0.1:5230;++ 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;++ }++++ location = /mcp {++ limit_except POST { deny all; }++ proxy_pass http://127.0.0.1:5230;++ 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;++ }++++ location = /robots.txt { proxy_pass http://127.0.0.1:5230; 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; }++ location = /sitemap.xml { proxy_pass http://127.0.0.1:5230; 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; }++ location = /llms.txt { proxy_pass http://127.0.0.1:5230; 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; }++ location = /llms-full.txt { proxy_pass http://127.0.0.1:5230; 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; }++ location = /release.json { proxy_pass http://127.0.0.1:5230; 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; }++ location /shared/ { proxy_pass http://127.0.0.1:5230; 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; }++++ location = / {++ proxy_pass http://127.0.0.1:5230;++ 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;++ }++++ # Everything else: the app answers GET/HEAD (its /docs, /favicon.ico, and its own 404 page); other methods++ # are refused here.++ location / {++ limit_except GET HEAD { deny all; }++ proxy_pass http://127.0.0.1:5230;++ 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;++ }++}nnew file mode 100644@@@ -0,0 +1,15 @@++[Unit]++Description=OpenVibe.Bench — the shared AI benchmark engine++After=network.target++++[Service]++Type=simple++User=openvibe++EnvironmentFile=/etc/openvibe/bench.env++ExecStart=/usr/bin/node /opt/openvibe/bench/server/index.js++Restart=on-failure++RestartSec=5++TimeoutStopSec=15++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,192 @@++-- phase: expand++-- OpenVibe.Bench (plan 2026-10-28 §4.12): the comparative-evaluation authority. Kinds, tasks, pinned rounds,++-- entries, judgements, ratings, private evals and the events outbox. Nothing written here is a credential; the++-- public dataset is anonymous by design and a judgement never carries a voter id into an export.++++CREATE TABLE bench_kinds (++ id text COLLATE "C" PRIMARY KEY, -- e.g. design.page++ room text COLLATE "C" NOT NULL,++ entry text COLLATE "C" NOT NULL,++ judge text COLLATE "C" NOT NULL, -- human_pairwise | rubric | automatic_grader | test_suite | game_outcome++ name text NOT NULL,++ schema jsonb NOT NULL DEFAULT '{}'::jsonb,++ metrics jsonb NOT NULL DEFAULT '[]'::jsonb,++ created_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT bench_kinds_id CHECK (id ~ '^[a-z0-9]+(\.[a-z0-9]+)*$'),++ CONSTRAINT bench_kinds_room CHECK (length(room) BETWEEN 1 AND 40),++ CONSTRAINT bench_kinds_judge CHECK (judge IN ('human_pairwise', 'rubric', 'automatic_grader', 'test_suite', 'game_outcome'))++);++++CREATE TABLE bench_tasks (++ id text COLLATE "C" PRIMARY KEY, -- btk_ + ULID++ kind_id text COLLATE "C" NOT NULL REFERENCES bench_kinds (id),++ prompt text NOT NULL,++ task_class text COLLATE "C", -- the class Route learns from++ public boolean NOT NULL DEFAULT false,++ created_by text COLLATE "C" NOT NULL,++ created_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT bench_tasks_id CHECK (id ~ '^btk_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT bench_tasks_prompt CHECK (length(prompt) BETWEEN 1 AND 4000),++ CONSTRAINT bench_tasks_public CHECK (public IN (true, false))++);++CREATE INDEX bench_tasks_by_kind ON bench_tasks (kind_id, created_at DESC);++++CREATE TABLE bench_rounds (++ id text COLLATE "C" PRIMARY KEY, -- brd_ + ULID++ task_id text COLLATE "C" NOT NULL REFERENCES bench_tasks (id),++ kind_id text COLLATE "C" NOT NULL REFERENCES bench_kinds (id),++ offers jsonb NOT NULL DEFAULT '[]'::jsonb, -- pinned { provider, model, version?, params? }++ status text COLLATE "C" NOT NULL DEFAULT 'queued', -- queued | running | ready | finished | failed++ seed integer NOT NULL DEFAULT 0,++ params jsonb NOT NULL DEFAULT '{}'::jsonb,++ pool_fallback boolean NOT NULL DEFAULT false,++ receipt text COLLATE "C", -- sha256 of the finished round's reproducible parts++ created_by text COLLATE "C" NOT NULL,++ created_at timestamptz NOT NULL DEFAULT now(),++ finished_at timestamptz,++ CONSTRAINT bench_rounds_id CHECK (id ~ '^brd_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT bench_rounds_status CHECK (status IN ('queued', 'running', 'ready', 'finished', 'failed')),++ CONSTRAINT bench_rounds_receipt CHECK (receipt IS NULL OR receipt ~ '^[0-9a-f]{64}$')++);++CREATE INDEX bench_rounds_status_created ON bench_rounds (status, created_at);++CREATE INDEX bench_rounds_by_kind ON bench_rounds (kind_id, created_at DESC);++CREATE INDEX bench_rounds_by_creator ON bench_rounds (created_by, created_at DESC);++++CREATE TABLE bench_entries (++ id text COLLATE "C" PRIMARY KEY, -- bte_ + ULID++ round_id text COLLATE "C" NOT NULL REFERENCES bench_rounds (id) ON DELETE CASCADE,++ provider text COLLATE "C" NOT NULL,++ model text COLLATE "C" NOT NULL,++ route jsonb NOT NULL DEFAULT '{}'::jsonb, -- the route that produced the entry++ payload jsonb NOT NULL DEFAULT '{}'::jsonb, -- the output (html, patch, transcript, game result, ...)++ cost_usd numeric(12, 6) NOT NULL DEFAULT 0,++ ms integer NOT NULL DEFAULT 0,++ tokens integer NOT NULL DEFAULT 0,++ error text,++ ui_check jsonb,++ run_id text,++ created_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT bench_entries_id CHECK (id ~ '^bte_[0-9A-HJKMNP-TV-Z]{26}$')++);++CREATE INDEX bench_entries_by_round ON bench_entries (round_id);++CREATE INDEX bench_entries_by_model ON bench_entries (model);++++CREATE TABLE bench_judgements (++ id text COLLATE "C" PRIMARY KEY, -- bju_ + ULID++ round_id text COLLATE "C" NOT NULL REFERENCES bench_rounds (id) ON DELETE CASCADE,++ kind_id text COLLATE "C" NOT NULL REFERENCES bench_kinds (id),++ judge text COLLATE "C" NOT NULL, -- the kind's judge++ judge_name text, -- named automatic grader / test suite++ judge_version text,++ method text COLLATE "C" NOT NULL, -- the kind's judge++ pair jsonb, -- { left, right } for a pairwise judgement++ outcome text COLLATE "C", -- left | right | tie | both_bad | pass | fail | draw++ score numeric(12, 6),++ subject text COLLATE "C", -- the person (usr_…) or service; never in the open dataset++ at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT bench_judgements_id CHECK (id ~ '^bju_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT bench_judgements_method CHECK (method IN ('human_pairwise', 'rubric', 'automatic_grader', 'test_suite', 'game_outcome')),++ CONSTRAINT bench_judgements_outcome CHECK (outcome IS NULL OR outcome IN ('left', 'right', 'tie', 'both_bad', 'pass', 'fail', 'draw'))++);++CREATE INDEX bench_judgements_by_round ON bench_judgements (round_id, at);++CREATE INDEX bench_judgements_by_kind ON bench_judgements (kind_id, at);++CREATE INDEX bench_judgements_by_subject ON bench_judgements (subject, at);++++CREATE TABLE bench_ratings (++ kind_id text COLLATE "C" NOT NULL,++ category text COLLATE "C" NOT NULL DEFAULT 'all',++ subject text COLLATE "C" NOT NULL, -- the reported model name++ elo numeric(8, 2) NOT NULL DEFAULT 1000,++ games integer NOT NULL DEFAULT 0,++ wins integer NOT NULL DEFAULT 0,++ losses integer NOT NULL DEFAULT 0,++ ties integer NOT NULL DEFAULT 0,++ win_rate numeric(8, 4) NOT NULL DEFAULT 0,++ ci_lower numeric(8, 2),++ ci_upper numeric(8, 2),++ median_cost_usd numeric(12, 6),++ median_ms numeric(12, 1),++ updated_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (kind_id, category, subject),++ CONSTRAINT bench_ratings_counters CHECK (games >= 0 AND wins >= 0 AND losses >= 0 AND ties >= 0)++);++CREATE INDEX bench_ratings_order ON bench_ratings (kind_id, category, elo DESC, games DESC);++++CREATE TABLE bench_evals (++ id text COLLATE "C" PRIMARY KEY, -- bev_ + ULID++ owner text COLLATE "C" NOT NULL, -- usr_… or prj_…; private by construction++ kind_id text COLLATE "C" NOT NULL REFERENCES bench_kinds (id),++ name text NOT NULL,++ test_suite jsonb NOT NULL DEFAULT '[]'::jsonb, -- the owner's held-out tasks++ created_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT bench_evals_id CHECK (id ~ '^bev_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT bench_evals_name CHECK (length(name) BETWEEN 1 AND 160)++);++CREATE INDEX bench_evals_by_owner ON bench_evals (owner, created_at DESC);++++CREATE TABLE bench_eval_runs (++ id text COLLATE "C" PRIMARY KEY, -- ber_ + ULID++ eval_id text COLLATE "C" NOT NULL REFERENCES bench_evals (id) ON DELETE CASCADE,++ offers jsonb NOT NULL DEFAULT '[]'::jsonb,++ status text COLLATE "C" NOT NULL DEFAULT 'queued', -- queued | running | ready | failed++ results jsonb, -- private results, never in the public dataset++ created_at timestamptz NOT NULL DEFAULT now(),++ finished_at timestamptz,++ CONSTRAINT bench_eval_runs_id CHECK (id ~ '^ber_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT bench_eval_runs_status CHECK (status IN ('queued', 'running', 'ready', 'failed'))++);++CREATE INDEX bench_eval_runs_by_eval ON bench_eval_runs (eval_id, created_at DESC);++++-- The events outbox (Today's shape). A round.finished / judgement.recorded / rating.changed row is written in++-- the same transaction as the change; the publisher claims and sends it to OpenVibe.Events.++CREATE TABLE bench_outbox (++ event_id text COLLATE "C" PRIMARY KEY,++ topic text COLLATE "C" NOT NULL,++ payload jsonb NOT NULL,++ created_at timestamptz NOT NULL DEFAULT now(),++ published_at timestamptz,++ attempts integer NOT NULL DEFAULT 0,++ last_error text,++ next_attempt_at timestamptz NOT NULL DEFAULT now(),++ claimed_at timestamptz,++ claimed_by text++);++CREATE INDEX bench_outbox_pending ON bench_outbox (next_attempt_at) WHERE published_at IS NULL;++++-- openvibe-sdk/account-data idempotency (ADR-033).++CREATE TABLE account_data_events (++ id text PRIMARY KEY,++ kind text NOT NULL,++ subject text NOT NULL,++ outcome jsonb,++ applied_at timestamptz NOT NULL DEFAULT now(),++ sent_at timestamptz++);++++-- Request telemetry (openvibe-sdk/telemetry).++CREATE TABLE telemetry_samples (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ at timestamptz NOT NULL,++ service text NOT NULL,++ project text,++ subject text,++ resource text,++ provider text,++ node text,++ cell text,++ region text,++ operation text NOT NULL,++ latency_ms double precision,++ queue_delay_ms double precision,++ ttfb_ms double precision,++ throughput_per_second double precision,++ bytes bigint,++ status text,++ cache_status text,++ cost_estimate double precision,++ route_epoch bigint,++ trace_id text,++ extra jsonb NOT NULL DEFAULT '{}'::jsonb++);++CREATE INDEX telemetry_samples_at_idx ON telemetry_samples (at);++CREATE INDEX telemetry_samples_service_idx ON telemetry_samples (service, operation, at);nnew file mode 100644@@@ -0,0 +1,1407 @@++{++ "name": "openvibe-bench",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-bench",++ "version": "0.1.0",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "express-rate-limit": "^7.5.0",++ "helmet": "^8.1.0",++ "iovalkey": "^0.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",++ "pg": "^8.23.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"++ },++ "engines": {++ "node": ">=22.0.0"++ }++ },++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.13",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",++ "integrity": "sha512-4rZuxK1k07w6sSpJzjMri0B9+HuXuC7V9pxVB51pR0T9KPqXg6TD0mdaEwBA9rkcL4jdht4tGDf/wYzAiGhT7g==",++ "devOptional": true,++ "license": "Apache-2.0"++ },++ "node_modules/@iovalkey/commands": {++ "version": "0.1.0",++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",++ "license": "MIT"++ },++ "node_modules/accepts": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",++ "license": "MIT",++ "dependencies": {++ "mime-types": "~2.1.34",++ "negotiator": "0.6.3"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/ajv": {++ "version": "8.20.0",++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",++ "license": "MIT",++ "dependencies": {++ "fast-deep-equal": "^3.1.3",++ "fast-uri": "^3.0.1",++ "json-schema-traverse": "^1.0.0",++ "require-from-string": "^2.0.2"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/epoberezkin"++ }++ },++ "node_modules/ajv-formats": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",++ "license": "MIT",++ "dependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependenciesMeta": {++ "ajv": {++ "optional": true++ }++ }++ },++ "node_modules/array-flatten": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",++ "license": "MIT"++ },++ "node_modules/body-parser": {++ "version": "1.20.9",++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz",++ "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "content-type": "~1.0.5",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "~1.2.0",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "on-finished": "~2.4.1",++ "qs": "~6.16.0",++ "raw-body": "~2.5.3",++ "type-is": "~1.6.18",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/buffer-equal-constant-time": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",++ "license": "BSD-3-Clause"++ },++ "node_modules/bytes": {++ "version": "3.1.2",++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/call-bind-apply-helpers": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/call-bound": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "get-intrinsic": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/cluster-key-slot": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/content-disposition": {++ "version": "0.5.4",++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "5.2.1"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/content-type": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie": {++ "version": "0.7.2",++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie-parser": {++ "version": "1.4.7",++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",++ "license": "MIT",++ "dependencies": {++ "cookie": "0.7.2",++ "cookie-signature": "1.0.6"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/cookie-signature": {++ "version": "1.0.6",++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",++ "license": "MIT"++ },++ "node_modules/debug": {++ "version": "2.6.9",++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",++ "license": "MIT",++ "dependencies": {++ "ms": "2.0.0"++ }++ },++ "node_modules/denque": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10"++ }++ },++ "node_modules/depd": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/destroy": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/dotenv": {++ "version": "16.6.1",++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=12"++ },++ "funding": {++ "url": "https://dotenvx.com"++ }++ },++ "node_modules/dunder-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.1",++ "es-errors": "^1.3.0",++ "gopd": "^1.2.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/ecdsa-sig-formatter": {++ "version": "1.0.11",++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/ee-first": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",++ "license": "MIT"++ },++ "node_modules/encodeurl": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/es-define-property": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-errors": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-object-atoms": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/escape-html": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",++ "license": "MIT"++ },++ "node_modules/etag": {++ "version": "1.8.1",++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/express": {++ "version": "4.22.3",++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",++ "license": "MIT",++ "dependencies": {++ "accepts": "~1.3.8",++ "array-flatten": "1.1.1",++ "body-parser": "~1.20.5",++ "content-disposition": "~0.5.4",++ "content-type": "~1.0.4",++ "cookie": "~0.7.1",++ "cookie-signature": "~1.0.6",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "finalhandler": "~1.3.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.0",++ "merge-descriptors": "1.0.3",++ "methods": "~1.1.2",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "path-to-regexp": "~0.1.13",++ "proxy-addr": "~2.0.7",++ "qs": "~6.16.0",++ "range-parser": "~1.2.1",++ "safe-buffer": "5.2.1",++ "send": "~0.19.0",++ "serve-static": "~1.16.2",++ "setprototypeof": "1.2.0",++ "statuses": "~2.0.1",++ "type-is": "~1.6.18",++ "utils-merge": "1.0.1",++ "vary": "~1.1.2"++ },++ "engines": {++ "node": ">= 0.10.0"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/express-rate-limit": {++ "version": "7.5.1",++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",++ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",++ "license": "MIT",++ "engines": {++ "node": ">= 16"++ },++ "funding": {++ "url": "https://github.com/sponsors/express-rate-limit"++ },++ "peerDependencies": {++ "express": ">= 4.11"++ }++ },++ "node_modules/fast-deep-equal": {++ "version": "3.1.3",++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",++ "license": "MIT"++ },++ "node_modules/fast-uri": {++ "version": "3.1.9",++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",++ "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fastify"++ },++ {++ "type": "opencollective",++ "url": "https://opencollective.com/fastify"++ }++ ],++ "license": "BSD-3-Clause"++ },++ "node_modules/finalhandler": {++ "version": "1.3.2",++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "statuses": "~2.0.2",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/forwarded": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/fresh": {++ "version": "0.5.2",++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/function-bind": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-intrinsic": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "es-define-property": "^1.0.1",++ "es-errors": "^1.3.0",++ "es-object-atoms": "^1.1.1",++ "function-bind": "^1.1.2",++ "get-proto": "^1.0.1",++ "gopd": "^1.2.0",++ "has-symbols": "^1.1.0",++ "hasown": "^2.0.2",++ "math-intrinsics": "^1.1.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",++ "license": "MIT",++ "dependencies": {++ "dunder-proto": "^1.0.1",++ "es-object-atoms": "^1.0.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/gopd": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/has-symbols": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/hasown": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",++ "license": "MIT",++ "dependencies": {++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/helmet": {++ "version": "8.3.0",++ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",++ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",++ "license": "MIT",++ "engines": {++ "node": ">=18.0.0"++ },++ "funding": {++ "url": "https://github.com/sponsors/EvanHahn"++ }++ },++ "node_modules/http-errors": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",++ "license": "MIT",++ "dependencies": {++ "depd": "~2.0.0",++ "inherits": "~2.0.4",++ "setprototypeof": "~1.2.0",++ "statuses": "~2.0.2",++ "toidentifier": "~1.0.1"++ },++ "engines": {++ "node": ">= 0.8"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/iconv-lite": {++ "version": "0.4.24",++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",++ "license": "MIT",++ "dependencies": {++ "safer-buffer": ">= 2.1.2 < 3"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/inherits": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",++ "license": "ISC"++ },++ "node_modules/iovalkey": {++ "version": "0.4.0",++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",++ "license": "MIT",++ "dependencies": {++ "@iovalkey/commands": "^0.1.0",++ "cluster-key-slot": "^1.1.0",++ "debug": "^4.3.4",++ "denque": "^2.1.0",++ "lodash.defaults": "^4.2.0",++ "lodash.isarguments": "^3.1.0",++ "redis-errors": "^1.2.0",++ "redis-parser": "^3.0.0",++ "standard-as-callback": "^2.1.0"++ },++ "engines": {++ "node": ">=18.12.0"++ }++ },++ "node_modules/iovalkey/node_modules/debug": {++ "version": "4.4.3",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/iovalkey/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/ipaddr.js": {++ "version": "1.9.1",++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.10"++ }++ },++ "node_modules/json-schema-traverse": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",++ "license": "MIT"++ },++ "node_modules/jsonwebtoken": {++ "version": "9.0.3",++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",++ "license": "MIT",++ "dependencies": {++ "jws": "^4.0.1",++ "lodash.includes": "^4.3.0",++ "lodash.isboolean": "^3.0.3",++ "lodash.isinteger": "^4.0.4",++ "lodash.isnumber": "^3.0.3",++ "lodash.isplainobject": "^4.0.6",++ "lodash.isstring": "^4.0.1",++ "lodash.once": "^4.0.0",++ "ms": "^2.1.1",++ "semver": "^7.5.4"++ },++ "engines": {++ "node": ">=12",++ "npm": ">=6"++ }++ },++ "node_modules/jsonwebtoken/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/jwa": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",++ "license": "MIT",++ "dependencies": {++ "buffer-equal-constant-time": "^1.0.1",++ "ecdsa-sig-formatter": "1.0.11",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/jws": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",++ "license": "MIT",++ "dependencies": {++ "jwa": "^2.0.1",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/lodash.defaults": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",++ "license": "MIT"++ },++ "node_modules/lodash.includes": {++ "version": "4.3.0",++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",++ "license": "MIT"++ },++ "node_modules/lodash.isarguments": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",++ "license": "MIT"++ },++ "node_modules/lodash.isboolean": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",++ "license": "MIT"++ },++ "node_modules/lodash.isinteger": {++ "version": "4.0.4",++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",++ "license": "MIT"++ },++ "node_modules/lodash.isnumber": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",++ "license": "MIT"++ },++ "node_modules/lodash.isplainobject": {++ "version": "4.0.6",++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",++ "license": "MIT"++ },++ "node_modules/lodash.isstring": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",++ "license": "MIT"++ },++ "node_modules/lodash.once": {++ "version": "4.1.1",++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",++ "license": "MIT"++ },++ "node_modules/math-intrinsics": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/media-typer": {++ "version": "0.3.0",++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/merge-descriptors": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/methods": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime": {++ "version": "1.6.0",++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",++ "license": "MIT",++ "bin": {++ "mime": "cli.js"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/mime-db": {++ "version": "1.52.0",++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime-types": {++ "version": "2.1.35",++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",++ "license": "MIT",++ "dependencies": {++ "mime-db": "1.52.0"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/ms": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",++ "license": "MIT"++ },++ "node_modules/negotiator": {++ "version": "0.6.3",++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/object-inspect": {++ "version": "1.13.4",++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/on-finished": {++ "version": "2.4.2",++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",++ "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",++ "license": "MIT",++ "dependencies": {++ "ee-first": "1.1.1"++ },++ "engines": {++ "node": ">= 0.8"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/openvibe-contracts": {++ "version": "0.238.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0",++ "integrity": "sha512-BQOsqor9ew75s2ElWK6g6pWk0gr5nbwzgfzMKJPBPIjnDnIkUQfNmsYw8AeexW5qeotCmfTu4lSPdqy6ZQaq5g==",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1"++ },++ "bin": {++ "openvibe-contracts-check": "bin/check-service.js"++ },++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/openvibe-sdk": {++ "version": "0.70.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",++ "integrity": "sha512-iMNxlnRm0fmyj60nHiC/lKAaG3JAqiJgu42lShnkby4yF4dQJRG3G0trrqOoULZKBalrYHb/gjauRwNQYN/tog==",++ "license": "MIT",++ "bin": {++ "openvibe-download": "bin/openvibe-download.js"++ },++ "engines": {++ "node": ">=20"++ },++ "peerDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0"++ },++ "peerDependenciesMeta": {++ "@electric-sql/pglite": {++ "optional": true++ },++ "iovalkey": {++ "optional": true++ },++ "pg": {++ "optional": true++ }++ }++ },++ "node_modules/openvibe-shared": {++ "version": "4.31.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",++ "integrity": "sha512-Zk1t1RGsiszXJEyW3x/cVCYC6U1qyLXID43l/Ehv2Py5gGglz5CfgYlPcDGI7n3QACsCWoOIGZuMBvlyfxyXEA==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ },++ "bin": {++ "openvibe-build-assets": "scripts/build-assets.js"++ }++ },++ "node_modules/parseurl": {++ "version": "1.3.3",++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/path-to-regexp": {++ "version": "0.1.13",++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",++ "license": "MIT"++ },++ "node_modules/pg": {++ "version": "8.23.1",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",++ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.1",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.1",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.1"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.1",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",++ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.1",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",++ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.1",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",++ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/proxy-addr": {++ "version": "2.0.8",++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",++ "license": "MIT",++ "dependencies": {++ "forwarded": "0.2.0",++ "ipaddr.js": "1.9.1"++ },++ "engines": {++ "node": ">= 0.10"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/qs": {++ "version": "6.16.2",++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",++ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",++ "license": "BSD-3-Clause",++ "dependencies": {++ "es-define-property": "^1.0.1",++ "side-channel": "^1.1.1"++ },++ "engines": {++ "node": ">=0.6"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/range-parser": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/raw-body": {++ "version": "2.5.3",++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/safe-buffer": {++ "version": "5.2.1",++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/safer-buffer": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",++ "license": "MIT"++ },++ "node_modules/semver": {++ "version": "7.8.5",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",++ "license": "ISC",++ "bin": {++ "semver": "bin/semver.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/send": {++ "version": "0.19.2",++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "1.2.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.1",++ "mime": "1.6.0",++ "ms": "2.1.3",++ "on-finished": "~2.4.1",++ "range-parser": "~1.2.1",++ "statuses": "~2.0.2"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/send/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/serve-static": {++ "version": "1.16.3",++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",++ "license": "MIT",++ "dependencies": {++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "parseurl": "~1.3.3",++ "send": "~0.19.1"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/setprototypeof": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",++ "license": "ISC"++ },++ "node_modules/side-channel": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4",++ "side-channel-list": "^1.0.1",++ "side-channel-map": "^1.0.1",++ "side-channel-weakmap": "^1.0.2"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-list": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-map": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-weakmap": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3",++ "side-channel-map": "^1.0.1"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },++ "node_modules/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },++ "node_modules/statuses": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/toidentifier": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",++ "license": "MIT",++ "engines": {++ "node": ">=0.6"++ }++ },++ "node_modules/type-is": {++ "version": "1.6.18",++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",++ "license": "MIT",++ "dependencies": {++ "media-typer": "0.3.0",++ "mime-types": "~2.1.24"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/unpipe": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/utils-merge": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4.0"++ }++ },++ "node_modules/vary": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,36 @@++{++ "name": "openvibe-bench",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe.Bench — the shared AI benchmark and comparison engine for every room.",++ "main": "server/index.js",++ "scripts": {++ "start": "node server/index.js",++ "dev": "NODE_ENV=development node server/index.js",++ "test": "node test/run.js",++ "test:pg": "BENCH_TEST_STORE=pg node test/run.js"++ },++ "keywords": [++ "openvibe"++ ],++ "author": "OpenVibers",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "express-rate-limit": "^7.5.0",++ "helmet": "^8.1.0",++ "iovalkey": "^0.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",++ "pg": "^8.23.0"++ },++ "engines": {++ "node": ">=22.0.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"++ }++}nnew file mode 100644@@@ -0,0 +1,63 @@++/* OpenVibe.Bench: the developer service's own pieces. Colours come from the theme (--bg-*, --text-*, --border) and the room's accent++ (.ov-room: --room-accent, --room-strong, --room-fill, --room-on), so every theme, light or dark, wears it. Phone++ first; nothing scrolls sideways. Boards are JSON here; Report draws them. */++*, *::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; }++.bm { display: block; }++.bm a { color: var(--room-strong); }++.bm .ovk-i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }++.bm-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }++.bm-narrow { max-width: 620px; }++.bm-page-pad { padding-top: clamp(28px, 6vw, 64px); }++.bm h2 { margin: 0; font-size: 1.2rem; font-weight: 800; letter-spacing: -.01em; }++.bm-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }++.bm-page-h { padding-top: clamp(20px, 4vw, 40px); margin-bottom: 22px; }++.bm-page-h h1, .bm-entry-h h1 { margin: 0; font-size: clamp(1.9rem, 5vw, 3rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.06; overflow-wrap: anywhere; }++.bm-lede, .bm-summary { margin: 10px 0 0; max-width: 700px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }++.bm-fine, .bm-muted { color: var(--text-muted, #7386a3); font-size: .88rem; line-height: 1.55; }++.bm-section { margin-top: clamp(28px, 4vw, 44px); }++.bm-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }++.bm-count { color: var(--text-muted, #7386a3); font-size: .86rem; }++.bm-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; }++.bm-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }++.bm-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--room-fill); color: var(--room-on) !important; font: inherit; font-weight: 700; font-size: .95rem; text-decoration: none; cursor: pointer; }++.bm-btn-quiet { background: transparent; color: var(--text-primary, #e6edf7) !important; border: 1px solid var(--border, #1f2d47); }++.bm-btn-small { min-height: 34px; padding: 0 14px; font-size: .86rem; }++.bm-btn.is-mine { background: color-mix(in srgb, var(--room-accent) 22%, transparent); color: var(--room-strong) !important; }++.bm-btn:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }++.bm-note { margin: 14px 0; padding: 11px 14px; border-radius: 14px; font-weight: 600; background: color-mix(in srgb, var(--room-accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--room-accent) 30%, transparent); }++.bm-note.is-bad { background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); border-color: color-mix(in srgb, var(--error, #ef4444) 35%, transparent); }++.bm-empty { display: grid; justify-items: start; gap: 10px; padding: 22px; border-radius: 18px; border: 1px dashed var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); }++.bm-empty p { margin: 0; line-height: 1.5; }++.bm-empty h1 { margin: 0; font-size: 1.6rem; color: var(--text-primary, #e6edf7); }++++++.bm-card { padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.bm-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 20px 0 0; }++.bm-facts > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }++.bm-facts dt { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.bm-facts dd { margin: 4px 0 0; font-weight: 700; overflow-wrap: anywhere; }++.bm-eps { display: grid; gap: 10px; margin: 12px 0 0; }++.bm-eps > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.bm-eps dt code { font-weight: 800; color: var(--room-strong); overflow-wrap: anywhere; }++.bm-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }++++/* The hero stands alone (no room picture): a wide headline, then the four promises as a row of cards */++.bm-hero { max-width: 880px; padding: clamp(1.5rem, 5vw, 4.5rem) 0 1rem; }++.bm-hero h1 { margin: 0 0 14px; font-size: clamp(2.1rem, 5.4vw, 4.2rem); line-height: 1.02; letter-spacing: -.035em; }++.bm-why { list-style: none; margin: 26px 0 8px; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr)); }++.bm-why li { display: grid; gap: 6px; padding: 16px 18px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-card, #0f1729); line-height: 1.5; }++.bm-why b { font-size: 1rem; color: var(--room-strong); }++.bm-why span { color: var(--text-secondary, #96a7c2); font-size: .92rem; }++.bm-count { min-width: 28px; padding: 2px 9px; border-radius: 999px; background: color-mix(in srgb, var(--room-accent) 16%, transparent); color: var(--room-strong); font-weight: 800; font-size: .8rem; text-align: center; font-variant-numeric: tabular-nums; }++++/* Kinds: a grid of monospace ids with their room and judge */++.bm-kinds { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 250px), 1fr)); }++.bm-kind { display: grid; gap: 4px; padding: 12px 14px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #0f1729); min-width: 0; }++.bm-kind:hover { border-color: var(--room-accent); }++.bm-kind-link { font: 700 .95rem/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--room-strong); text-decoration: none; overflow-wrap: anywhere; }++.bm-kind-link:hover { text-decoration: underline; }++.bm-kind-link:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; border-radius: 4px; }++.bm-kind-meta { font-size: .82rem; color: var(--text-muted, #7386a3); }nnew file mode 100644@@@ -0,0 +1 @@++/* OpenVibe.Bench pages work without JavaScript; the main session designs this room later. */nnew file mode 100644@@@ -0,0 +1,174 @@++'use strict';++++/**++ * OpenVibe.Bench — Express app factory; server/index.js listens, tests build their own instance with a temporary++ * database and a mock Network/AI.++ *++ * /, /docs the pages (http/pages.js)++ * /api/v1/* the API (http/api.js)++ * /mcp the MCP endpoint (mcp.js)++ * /auth/* Network SSO with PKCE (auth/sso.js)++ * /internal/events ADR-033 account export/deletion++ * /api/health, /api/ready, /release.json, /metrics machine endpoints++ */++const path = require('path');++const express = require('express');++const helmet = require('helmet');++const cookieParser = require('cookie-parser');++const rateLimit = require('express-rate-limit');++const contracts = require('openvibe-contracts');++const cache = require('openvibe-shared/cache-policy');++++const configLib = require('./config');++const { openStore } = require('./db');++const { createKeyStore } = require('./auth/keys');++const { createSso } = require('./auth/sso');++const { createPrincipal } = require('./http/principal');++const { createApi } = require('./http/api');++const { createPageRoutes } = require('./http/pages');++const { createServiceReadiness } = require('./observability');++const { assetVersion, send } = require('./render/layout');++const { raw } = require('./render/html');++++const accountDataLib = require('./identity/account-data');++const { createStore } = require('./store');++const { createBenchAi } = require('./bench/ai');++const { createBenchJobs } = require('./bench/jobs');++const { createPublisher } = require('./events/publisher');++const { mcpRouter } = require('./mcp');++const { createNetworkSender } = require('openvibe-sdk/account-data');++++const PUBLIC_DIR = path.join(__dirname, '..', 'public');++const VERSION = require('../package.json').version;++const { createHttpTelemetry } = require('openvibe-sdk/service');++const { createPgTelemetrySink } = require('openvibe-sdk/telemetry');++++/**++ * opts: config, store, bench, benchAi, accountSend, telemetrySink, fetchImpl, log, now, random.++ */++async function createApp(opts = {}) {++ const config = opts.config || configLib.load();++ const log = opts.log || console;++ const fetchImpl = opts.fetchImpl || globalThis.fetch;++ const s = opts.store || await openStore(config, { now: opts.now, log });++++ const keys = createKeyStore({ config, fetchImpl, log });++ const sso = createSso({ config, keys, fetchImpl, now: opts.now || (() => Date.now()), log });++ const principal = createPrincipal({ config, keys });++++ const bench = opts.bench || createStore(s, { config, log, random: opts.random });++ const ai = opts.benchAi || createBenchAi({ config, fetchImpl, log });++ const jobs = createBenchJobs({ s, bench, ai, config, log });++ const publisher = createPublisher({ config, s, fetchImpl, log });++++ await bench.seedKinds();++++ const accountData = accountDataLib.create({ db: s.db, log });++ const accountSend = opts.accountSend || (config.oauth.clientSecret++ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })++ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Bench cannot answer account events'); });++++ const ctx = {++ config, s, keys, sso, principal, bench, ai, jobs, publisher, accountData, accountSend, log,++ random: opts.random || Math.random,++ };++++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', config.trustProxy);++ const release = require('openvibe-shared/release').createRelease({ service: 'bench', root: path.join(__dirname, '..') });++ require('./render/layout').setRelease(release.release);++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'bench', release: release.release });++ app.locals.metrics = metrics.registry;++ ctx.telemetry = createHttpTelemetry({ service: 'bench', sink: opts.telemetrySink || createPgTelemetrySink({ db: s.db, log }), skipPrefixes: ['/shared'], intervalMs: 60_000 });++ app.use(ctx.telemetry.middleware());++ app.locals.ctx = ctx;++++ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);++ ctx.valkey = valkey;++++ app.use(contracts.http.middleware());++ app.use(helmet({++ contentSecurityPolicy: {++ directives: {++ defaultSrc: ["'self'"],++ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],++ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],++ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],++ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],++ connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],++ frameSrc: ["'self'", 'https://openvibe.network'],++ frameAncestors: ["'none'"],++ objectSrc: ["'none'"],++ baseUri: ["'self'"],++ formAction: ["'self'", 'https://openvibe.network'],++ },++ },++ frameguard: { action: 'deny' },++ crossOriginEmbedderPolicy: false,++ crossOriginResourcePolicy: { policy: 'same-site' },++ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },++ }));++ app.use(cookieParser());++++ // ── Machine endpoints ───────────────────────────────────++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-bench', name: 'OpenVibe.Bench', version: VERSION }));++ release.mount(app, { registry: metrics.registry });++ const readiness = createServiceReadiness({ s, config, release: release.release, valkey });++ app.get('/api/ready', readiness.handler);++++ // ── Who is signed in ────────────────────────────────────++ app.use(sso.middleware());++++ // ── Sign-in ─────────────────────────────────────────────++ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));++ app.use('/auth', sso.routes());++++ // ── OpenVibe.Events → this service (loopback only) ──────++ app.use('/internal/events', accountData.consumer({++ secrets: config.events.secrets,++ send: accountSend,++ now: s.now,++ onEvent: async (ev) => `ignored:${ev.event_type}`,++ }));++++ // ── Static assets ───────────────────────────────────────++ 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 and MCP ─────────────────────────────────────────++ app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.BENCH_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));++ app.use('/api/v1', createApi(ctx));++ app.use(mcpRouter(ctx));++ app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));++++ // ── Pages ───────────────────────────────────────────────++ app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.BENCH_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));++ app.use(createPageRoutes(ctx));++ app.use((req, res) => send(res, 404, {++ viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',++ body: raw(require('./views').notFound()),++ }));++++ // eslint-disable-next-line no-unused-vars++ app.use((err, req, res, _next) => {++ log.error('[OpenVibe.Bench]', err && err.message ? err.message.slice(0, 300) : err);++ if (res.headersSent) return;++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (err && err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });++ if (err && err.type === 'entity.too.large') return req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov }) : res.status(413).type('text/plain').send('That request was too large.');++ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });++ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');++ });++++ return { app, ctx };++}++++module.exports = { createApp };nnew file mode 100644@@@ -0,0 +1,66 @@++'use strict';++++/**++ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the internal URL and kept fresh++ * by openvibe-sdk/auth's JWKS client. Tokens are verified offline against it.++ */++const sdk = require('openvibe-sdk/auth');++++const KEY_UNAVAILABLE = 'signing key not loaded yet';++const DOES_NOT_VERIFY = 'token does not verify';++++function publicReason(err) {++ const code = err && err.code;++ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;++ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;++}++++function jwksUrl(config) {++ return (config.networkInternalUrl || config.networkUrl) + '/api/.well-known/jwks';++}++++function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {++ const url = jwksUrl(config);++ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });++ let mirror = null;++++ async function refreshMirror() {++ try {++ const ks = await client.keys();++ const first = ks && ks[0];++ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;++ } catch (err) { log.warn(`[OpenVibe.Bench] JWKS mirror refresh failed: ${err && err.message || err}`); mirror = null; }++ }++ async function ensure() { await refreshMirror(); }++ function get() { return mirror ? mirror.pem : null; }++ function loaded() { return Boolean(mirror); }++++ const asMs = (v) => (typeof v === 'function' ? v() : (typeof v === 'number' ? v : Date.now()));++++ async function verifyUser(token, { issuer, audience, now = Date.now() } = {}) {++ try {++ const claims = await sdk.verifyUserToken(token, { jwks: url, issuer, audience, now: asMs(now), log });++ await refreshMirror();++ return { ok: true, claims };++ } catch (err) {++ await refreshMirror();++ const code = err && err.code;++ log.warn(`[OpenVibe.Bench] user token rejected (${code || 'error'}):`, (err && err.message) || err);++ return { ok: false, reason: publicReason(err), code, expired: code === 'token.expired' };++ }++ }++++ async function pemForToken(token) {++ let kid = null;++ try { kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { /* refused */ }++ try {++ const ks = await client.keysForKid(kid);++ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];++ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;++ } catch { return null; }++ }++++ return { ensure, get, loaded, status: client.status, verifyUser, pemForToken, client };++}++++module.exports = { createKeyStore, jwksUrl };nnew file mode 100644@@@ -0,0 +1,258 @@++'use strict';++++/**++ * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `bench`.++ *++ * GET /auth/login → Network /oauth/authorize++ * GET /auth/callback → state check, server-side code exchange++ * GET /auth/logout → clear cookies, revoke the refresh token (best effort)++ * GET /auth/me → { user } for the shared navbar++ *++ * The person's Network access token lives in the httpOnly `bench_at` cookie; `bench_rt` is the refresh token and++ * `bench_oauth` the short-lived PKCE flow. None are stored server-side, rendered or logged.++ */++const crypto = require('crypto');++const express = require('express');++const cache = require('openvibe-shared/cache-policy');++++const ACCESS_COOKIE = 'bench_at';++const REFRESH_COOKIE = 'bench_rt';++const HINT_COOKIE = 'ov_sso_hint';++const FLOW_COOKIE = 'bench_oauth';++++function pkcePair() {++ const verifier = crypto.randomBytes(32).toString('base64url');++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');++ return { verifier, challenge };++}++++function ownOrigins(config) {++ const out = new Set();++ for (const u of [config && config.baseUrl]) {++ try { if (u) out.add(new URL(u).origin); } catch { /* not a URL */ }++ }++ return out;++}++++function sanitizeNext(next, config) {++ if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';++ if (typeof next === 'string' && /^https?:\/\//i.test(next)) {++ let u = null;++ try { u = new URL(next); } catch { return '/'; }++ if (!ownOrigins(config).has(u.origin)) return '/';++ next = `${u.pathname}${u.search}${u.hash}`;++ }++ if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';++ return next;++}++++function authorizeUrl(config, { state, challenge, silent = false }) {++ const q = new URLSearchParams({++ response_type: 'code',++ client_id: config.oauth.clientId,++ redirect_uri: config.oauth.redirectUri,++ scope: config.oauth.scope,++ state,++ code_challenge: challenge,++ code_challenge_method: 'S256',++ });++ if (silent) q.set('prompt', 'none');++ return `${config.networkUrl}/oauth/authorize?${q.toString()}`;++}++++function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {++ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });++++ function setSession(res, data) {++ res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });++ if (data.refresh_token) res.cookie(REFRESH_COOKIE, data.refresh_token, { ...cookieBase(), maxAge: 30 * 24 * 60 * 60 * 1000 });++ res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });++ }++ function clearSession(res) {++ res.clearCookie(ACCESS_COOKIE, cookieBase());++ res.clearCookie(REFRESH_COOKIE, cookieBase());++ }++++ async function tokenGrant(fields) {++ const body = new URLSearchParams({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...fields });++ let res;++ try {++ res = await fetchImpl(`${config.networkInternalUrl}/oauth/token`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },++ body, signal: AbortSignal.timeout(10_000),++ });++ } catch (err) {++ throw Object.assign(new Error('OpenVibe.Network did not answer'), { status: 502, error: err && err.name === 'TimeoutError' ? 'timeout' : 'unreachable' });++ }++ const data = await res.json().catch(() => null);++ if (!res.ok || !data || !data.access_token) {++ throw Object.assign(new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`),++ { status: res.status, error: (data && data.error) || 'invalid_grant' });++ }++ return data;++ }++++ async function verifySession(token) {++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });++ const c = v.claims;++ if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };++ return v;++ }++++ function viewerFromClaims(claims, token) {++ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;++ const role = typeof claims.role === 'string' ? claims.role : 'user';++ return {++ kind: 'user',++ subject,++ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,++ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (typeof claims.username === 'string' ? claims.username.slice(0, 64) : 'you'),++ role,++ avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,++ profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,++ token,++ };++ }++++ async function refresh(req, res) {++ if (!req.sessionRefresh) req.sessionRefresh = await doRefresh(req, res);++ return req.sessionRefresh;++ }++++ async function doRefresh(req, res) {++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];++ if (!rt) return null;++ try {++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });++ setSession(res, data);++ const v = await verifySession(data.access_token);++ return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;++ } catch (err) {++ if (err.status && err.status < 500) clearSession(res);++ return null;++ }++ }++++ function middleware() {++ return async (req, res, next) => {++ req.viewer = { kind: 'anonymous' };++ const header = String(req.get('authorization') || '');++ const bearerToken = /^Bearer\s+\S/i.test(header) ? header.replace(/^Bearer\s+/i, '').trim() : null;++ if (bearerToken) {++ await keys.ensure();++ const v = await verifySession(bearerToken);++ if (v.ok) req.viewer = viewerFromClaims(v.claims, bearerToken);++ return next();++ }++ const token = req.cookies && req.cookies[ACCESS_COOKIE];++ if (!token) return next();++ await keys.ensure();++ const v = await verifySession(token);++ if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }++ if (v.expired) {++ const fresh = await refresh(req, res);++ if (fresh) { req.viewer = fresh; return next(); }++ }++ if (keys.loaded()) clearSession(res);++ next();++ };++ }++++ async function asViewer(req, res, fn) {++ try {++ return await fn(req.viewer.token);++ } catch (err) {++ if (!err || err.status !== 401) throw err;++ const fresh = await refresh(req, res);++ if (!fresh) throw err;++ req.viewer = fresh;++ return fn(fresh.token);++ }++ }++++ function routes() {++ const r = express.Router();++ const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });++++ r.get('/login', (req, res) => {++ const silent = Boolean(req.query.silent) && req.query.silent !== '0';++ const state = crypto.randomBytes(16).toString('hex');++ const { verifier, challenge } = pkcePair();++ res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next, config), silent }), flowOpts());++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ res.redirect(authorizeUrl(config, { state, challenge, silent }));++ });++++ r.get('/callback', async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ let flow = null;++ try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }++ res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });++ const next = flow ? sanitizeNext(flow.next, config) : '/';++ const { code, state, error } = req.query;++ if (error) {++ if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);++ return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);++ }++ if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'++ || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {++ return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');++ }++ if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');++ try {++ const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: config.oauth.redirectUri, code_verifier: flow.verifier });++ setSession(res, data);++ return res.redirect(next);++ } catch (err) {++ log.warn('[OpenVibe.Bench] sign-in exchange failed:', err.status || '', err.error || '');++ return res.status(err.status && err.status < 500 ? 400 : 502).type('text/plain')++ .send(err.status && err.status < 500 ? 'The Network refused the sign-in code. Please sign in again.' : 'Sign-in failed: OpenVibe.Network did not answer. Please try again.');++ }++ });++++ const sameOriginRequest = (req) => {++ const site = String(req.get('sec-fetch-site') || '');++ if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');++ const origin = req.get('origin');++ if (origin) return origin === `${req.protocol}://${req.get('host')}`;++ return req.method === 'GET';++ };++ r.get('/logout', (req, res, next) => {++ if (sameOriginRequest(req)) return next();++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next, config))}` : '';++ res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`++ + `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`++ + `<p>Sign out of OpenVibe.Bench?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);++ });++ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));++ r.all('/logout', async (req, res) => {++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];++ if (rt) {++ fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {++ method: 'POST', headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: rt }),++ signal: AbortSignal.timeout(3000),++ }).catch(() => {});++ }++ clearSession(res);++ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });++ res.redirect(303, sanitizeNext(req.query.next, config));++ });++++ r.get('/me', (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ const c = req.cookies || {};++ if (!c[ACCESS_COOKIE] && !c[REFRESH_COOKIE] && !req.get('authorization')) return res.json({ user: null });++ const v = req.viewer;++ if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });++ res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role, avatar_url: v.avatarUrl || null, profile_color: v.profileColor || null } });++ });++ return r;++ }++++ return { middleware, routes, asViewer, tokenGrant, pkcePair };++}++++module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE, FLOW_COOKIE };nnew file mode 100644@@@ -0,0 +1,266 @@++'use strict';++++/**++ * Bench's only model gateway: OpenVibe.AI's `generate` operation, called as the first-party service `bench` with++ * a client-credentials token (audience openvibe.ai, scope ai.run.create; Network grants the ai.generate++ * namespace). AI's routing is the authority: Bench reads GET /api/v1/routes?operation=generate and pins each slot++ * as { provider, model }. The reported provenance always wins, so a fallback is never mislabelled.++ */++const { serviceAuth } = require('openvibe-contracts');++const { sanitizeHtml } = require('./sanitize');++++class BenchAiError extends Error {++ constructor(status, code, detail) {++ super(detail || code);++ this.name = 'BenchAiError';++ this.status = status;++ this.code = code;++ this.detail = detail || code;++ }++}++++function createBenchAi({ config, fetchImpl = globalThis.fetch, log = console }) {++ const ai = config.ai;++ const tokenClient = config.oauth.clientSecret++ ? serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl || config.networkUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: ai.audience,++ scope: ai.scope,++ fetchImpl,++ })++ : null;++ const enabled = Boolean(ai.url && tokenClient);++++ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++++ async function call(method, path, body, { retried = false } = {}) {++ if (!enabled) throw new BenchAiError(503, 'ai.not_configured', 'OpenVibe.AI is not configured for Bench.');++ let headers;++ try {++ headers = { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokenClient.authHeaders()) };++ } catch (err) {++ throw new BenchAiError(502, 'ai.token_failed', 'OpenVibe.Network did not issue an AI token for Bench.');++ }++ let res;++ try {++ res = await fetchImpl(`${ai.url.replace(/\/+$/, '')}${path}`, {++ method,++ headers,++ body: body ? JSON.stringify(body) : undefined,++ signal: AbortSignal.timeout(ai.timeoutMs),++ });++ } catch (err) {++ throw new BenchAiError(502, 'ai.unreachable', err && err.name === 'TimeoutError'++ ? 'OpenVibe.AI did not answer in time.'++ : 'OpenVibe.AI could not be reached.');++ }++ if (res.status === 401 && !retried) {++ tokenClient.invalidate();++ return call(method, path, body, { retried: true });++ }++ const json = await res.json().catch(() => null);++ if (!res.ok) {++ const code = (json && (json.code || json.error)) || `ai.http_${res.status}`;++ const detail = (json && (json.detail || json.message)) || `OpenVibe.AI refused the run (${res.status}).`;++ throw new BenchAiError(res.status >= 500 ? 502 : res.status, String(code).slice(0, 80), String(detail).slice(0, 500));++ }++ return json;++ }++++ async function offeredModels() {++ let body;++ try { body = await call('GET', '/api/v1/routes?operation=generate'); } catch (err) {++ log.warn('[OpenVibe.Bench] AI routes unavailable:', (err && err.message) || err);++ return [];++ }++ let routes = Array.isArray(body) ? body : (body && Array.isArray(body.routes) ? body.routes : []);++ if (!routes.length && body && body.routes && typeof body.routes === 'object' && !Array.isArray(body.routes)) {++ routes = Object.entries(body.routes).map(([key, r]) => ({ key, ...(r || {}) }));++ }++ if (!routes.length && body && body.key) routes = [body];++ if (!routes.length && body && Array.isArray(body.models)) {++ routes = body.models.map((m) => ({ key: 'default.chat', capability: 'chat', primary: { provider: m.provider || m.provider_key, model: m.model || m.model_key } }));++ }++ const chat = routes.filter((r) => r && (r.capability === 'chat' || /chat|generate/i.test(String(r.key || ''))));++ const ordered = [++ ...chat.filter((r) => r.key === 'default.chat'),++ ...chat.filter((r) => r.key !== 'default.chat'),++ ];++ const out = [];++ const seen = new Set();++ const add = (provider, model) => {++ const p = String(provider || '').trim();++ const m = model == null ? null : String(model).trim();++ if (!p) return;++ const key = `${p}|${m || ''}`;++ if (seen.has(key)) return;++ seen.add(key);++ out.push({ provider: p, model: m || null });++ };++ const hasOffers = ordered.some((r) => Array.isArray(r.offers));++ if (hasOffers) {++ const preferred = ordered.find((r) => r.key === 'default.chat' && Array.isArray(r.offers));++ const source = preferred ? [preferred] : ordered.filter((r) => Array.isArray(r.offers));++ for (const r of source) for (const o of r.offers) add(o && o.provider, o && o.model);++ } else {++ for (const r of ordered) {++ if (r.primary) add(r.primary.provider, r.primary.model);++ for (const p of Array.isArray(r.pinned) ? r.pinned : []) add(p && p.provider, p && p.model);++ for (const f of Array.isArray(r.fallbacks) ? r.fallbacks : []) add(f && f.provider, f && f.model);++ }++ }++ return rankOffers(out);++ }++++ function rankOffers(list) {++ const out = [];++ const seen = new Set();++ const providers = new Set();++ const rest = [];++ for (const c of list) {++ const p = String((c && c.provider) || '').trim();++ if (!p) continue;++ const m = c.model == null ? null : String(c.model).trim();++ const key = `${p}|${m || ''}`;++ if (seen.has(key)) continue;++ seen.add(key);++ const offer = { provider: p, model: m || null };++ if (!providers.has(p)) { providers.add(p); out.push(offer); } else rest.push(offer);++ }++ return [...out, ...rest];++ }++++ function configuredModels() {++ return (ai.models || []).map((s) => {++ const i = s.indexOf(':');++ return i > 0 ? { provider: s.slice(0, i), model: s.slice(i + 1) || null } : { provider: s, model: null };++ });++ }++++ async function slotPlan() {++ let list = configuredModels();++ if (!list.length) list = await offeredModels();++ const ranked = rankOffers(list);++ const slots = ranked.slice(0, 3);++ const poolFallback = slots.length < 3;++ while (slots.length < 3) slots.push({ provider: 'ai', model: null });++ return { slots, pool_fallback: poolFallback, offered: ranked.length };++ }++++ async function slots() { return (await slotPlan()).slots; }++++ function systemPrompt(kind) {++ return [++ 'You are a capable model competing in the OpenVibe.Bench benchmark.',++ 'Answer the task the person describes, and return only the requested output with no extra explanation.',++ `Benchmark kind: ${kind}.`,++ ].join('\n');++ }++++ function textOf(output) {++ if (typeof output === 'string') return output;++ if (!output || typeof output !== 'object') return '';++ for (const k of ['text', 'html', 'content', 'body']) if (typeof output[k] === 'string') return output[k];++ if (output.message && typeof output.message.content === 'string') return output.message.content;++ return '';++ }++++ function msOf(run, fallback) {++ for (const v of [run && run.ms, run && run.latency_ms, run && run.duration_ms, run && run.usage && run.usage.ms, run && run.usage && run.usage.latency_ms, run && run.usage && run.usage.duration_ms]) {++ if (Number.isFinite(Number(v)) && Number(v) >= 0) return Math.round(Number(v));++ }++ const start = run && (run.started_at || run.created_at);++ const end = run && run.finished_at;++ if (start && end) {++ const d = Date.parse(end) - Date.parse(start);++ if (Number.isFinite(d) && d >= 0) return Math.round(d);++ }++ return Math.max(0, Math.round(fallback));++ }++++ async function waitFor(run, startedAt) {++ let r = run;++ const deadline = Date.now() + ai.timeoutMs;++ while (r && ['queued', 'running'].includes(r.status)) {++ if (!r.id) throw new BenchAiError(502, 'ai.bad_answer', 'OpenVibe.AI answered with a running run that has no id.');++ if (Date.now() > deadline) throw new BenchAiError(504, 'ai.timeout', `The AI run ${r.id} did not finish in time.`);++ await sleep(ai.pollMs);++ const body = await call('GET', `/api/v1/runs/${encodeURIComponent(r.id)}`);++ r = body && body.run ? body.run : body;++ }++ if (!r) throw new BenchAiError(502, 'ai.bad_answer', 'OpenVibe.AI answered without a run.');++ const done = ['succeeded', 'cached', 'completed', 'ok'].includes(r.status)++ || (r.status == null && r.output != null && !r.error);++ if (!done) {++ const e = r.error || {};++ throw new BenchAiError(502, 'ai.run_failed', `The AI run ${r.id || '?'} ${r.status || 'did not finish'}${e.code ? ` (${e.code}: ${e.detail || ''})` : ''}.`);++ }++ return { run: r, ms: msOf(r, Date.now() - startedAt) };++ }++++ async function generate({ prompt, kind, slot, roundId, index }) {++ if (!enabled) throw new BenchAiError(503, 'ai.not_configured', 'OpenVibe.AI is not configured for Bench.');++ const pinned = slot && slot.provider && slot.provider !== 'ai' ? { provider: slot.provider, model: slot.model || null } : null;++ const body = {++ prompt,++ system: systemPrompt(kind),++ max_tokens: 8000,++ temperature: 0.7,++ options: { cache: false },++ ...(pinned ? { route: pinned } : {}),++ target: { service: 'bench', type: 'bench_output', id: `${roundId}_${index}`.slice(0, 128) },++ };++ const startedAt = Date.now();++ const wait = Math.min(ai.waitMs, 60_000);++ let created;++ try {++ created = await call('POST', `/api/v1/generate?wait=${wait}`, body);++ } catch (err) {++ if (!err || err.status !== 404) throw err;++ created = await call('POST', `/api/v1/runs?wait=${wait}`, {++ workflow: 'ai.generate',++ input: { prompt: body.prompt, system: body.system, max_tokens: body.max_tokens, temperature: body.temperature },++ options: body.options,++ ...(body.route ? { route: body.route } : {}),++ target: body.target,++ });++ }++ const first = created && created.run ? created.run : created;++ const { run, ms } = await waitFor(first, startedAt);++ const text = textOf(run.output);++ if (!text.trim()) throw new BenchAiError(502, 'ai.empty', `The AI run ${run.id} returned no output.`);++ const usage = run.usage || {};++ const prov = run.provenance || {};++ const tokens = usage.tokens && typeof usage.tokens === 'object'++ ? (Number(usage.tokens.input) || 0) + (Number(usage.tokens.output) || 0)++ : Number.isFinite(Number(usage.tokens)) ? Number(usage.tokens)++ : (Number(usage.tokens_in) || 0) + (Number(usage.tokens_out) || 0)++ || (Number(usage.input_tokens) || 0) + (Number(usage.output_tokens) || 0)++ || (Number(run.tokens) || 0);++ const model = String(prov.model || run.model || (run.output && run.output.model) || (slot && slot.model) || 'unknown').slice(0, 120);++ const provider = String(prov.provider || run.provider || (slot && slot.provider) || 'ai').slice(0, 80);++ const ui_check = run.ui_check || (run.output && run.output.ui_check) || null;++ const html = /html_page/.test(kind || '') ? sanitizeHtml(text, { maxBytes: ai.maxHtmlBytes }) : text;++ const payload = /html_page/.test(kind || '') ? { html } : { text };++ return {++ payload,++ html,++ cost_usd: Number(usage.cost_usd != null ? usage.cost_usd++ : usage.cost != null ? usage.cost++ : run.cost_usd != null ? run.cost_usd++ : run.cost != null ? run.cost : 0) || 0,++ ms,++ tokens: Math.max(0, Math.round(tokens)),++ model,++ provider,++ ui_check,++ run_id: run.id || null,++ };++ }++++ return { enabled, slots, slotPlan, offeredModels, rankOffers, generate, systemPrompt };++}++++module.exports = { createBenchAi, BenchAiError };nnew file mode 100644@@@ -0,0 +1,152 @@++'use strict';++++/**++ * Bench's generation workers. A round (or private eval run) is created as `queued`; this loop claims it, asks++ * OpenVibe.AI for outputs, stores each with its reported provider/model/cost/time/tokens, and marks it `finished`++ * (at least two usable entries) or `failed`. The loop starts in server/index.js and never under BENCH_DRILL=1;++ * tests call tick() explicitly.++ */++const ERROR_MAX = 500;++++function createBenchJobs({ s, bench, ai, config, log = console }) {++ let timer = null;++ let running = false;++ let stopped = false;++++ async function claimRound() {++ return await s.tx(async () => {++ const r = await s.db.maybe("SELECT * FROM bench_rounds WHERE status = 'queued' ORDER BY created_at, id LIMIT 1 FOR UPDATE");++ if (!r) return null;++ await s.db.exec("UPDATE bench_rounds SET status = 'running' WHERE id = $1", [r.id]);++ return { ...r, status: 'running' };++ });++ }++++ async function generateRound(round) {++ const task = await bench.getTask(round.task_id);++ if (!task) throw new Error(`round ${round.id} has no task`);++ const kind = await bench.getKind(round.kind_id);++ if (!kind) throw new Error(`round ${round.id} has no kind`);++ let plan;++ try {++ plan = round.offers && round.offers.length++ ? { slots: round.offers, pool_fallback: round.offers.length < 3 }++ : (typeof ai.slotPlan === 'function' ? await ai.slotPlan() : { slots: await ai.slots(), pool_fallback: false });++ } catch (err) {++ log.warn('[OpenVibe.Bench] AI slots unavailable:', (err && err.message) || err);++ plan = { slots: [{ provider: 'ai', model: null }], pool_fallback: true };++ }++ const slots = (Array.isArray(plan.slots) ? plan.slots : []).slice(0, 3);++ while (slots.length < 3) slots.push({ provider: 'ai', model: null });++ let ok = 0;++ for (let i = 0; i < slots.length; i++) {++ const slot = slots[i] || { provider: 'ai', model: null };++ try {++ const out = await ai.generate({ prompt: task.prompt, kind: kind.entry, slot, roundId: round.id, index: i });++ await bench.addEntry({++ roundId: round.id, provider: out.provider, model: out.model,++ route: { provider: out.provider, model: out.model },++ payload: out.payload, costUsd: out.cost_usd, ms: out.ms, tokens: out.tokens,++ uiCheck: out.ui_check, runId: out.run_id,++ });++ ok++;++ } catch (err) {++ const detail = String((err && (err.detail || err.message)) || err).slice(0, ERROR_MAX);++ log.warn(`[OpenVibe.Bench] round ${round.id} output ${i} failed:`, detail);++ await bench.addEntry({++ roundId: round.id,++ provider: String((slot && slot.provider) || 'ai').slice(0,80),++ model: String((slot && slot.model) || 'unknown').slice(0,120),++ route: { provider: slot && slot.provider, model: slot && slot.model },++ payload: {}, error: detail,++ });++ }++ }++ if (ok < 2) await s.db.exec("UPDATE bench_rounds SET status = 'failed' WHERE id = $1", [round.id]);++ return ok;++ }++++ async function tickRound() {++ const round = await claimRound();++ if (!round) return null;++ try {++ await generateRound(round);++ return await bench.getRound(round.id);++ } catch (err) {++ log.warn('[OpenVibe.Bench] round job failed:', (err && err.message) || err);++ await s.db.exec("UPDATE bench_rounds SET status = 'failed' WHERE id = $1", [round.id]).catch(() => {});++ throw err;++ }++ }++++ async function claimEvalRun() {++ return await s.tx(async () => {++ const r = await s.db.maybe("SELECT * FROM bench_eval_runs WHERE status = 'queued' ORDER BY created_at, id LIMIT 1 FOR UPDATE");++ if (!r) return null;++ await s.db.exec("UPDATE bench_eval_runs SET status = 'running' WHERE id = $1", [r.id]);++ return { ...r, status: 'running' };++ });++ }++++ async function generateEvalRun(run) {++ const ev = await bench.getEval(run.eval_id, { owner: null });++ if (!ev) throw new Error(`eval run ${run.id} has no eval`);++ const results = [];++ for (const task of (ev.test_suite || [])) {++ const offer = (run.offers && run.offers[0]) || { provider: 'ai', model: null };++ try {++ const out = await ai.generate({ prompt: String(task.prompt || task), kind: (await bench.getKind(ev.kind_id)).entry, slot: offer, roundId: run.id, index: results.length });++ results.push({ task: String(task.prompt || task).slice(0, 500), offer, provider: out.provider, model: out.model, payload: out.payload, cost_usd: out.cost_usd, ms: out.ms });++ } catch (err) {++ results.push({ task: String(task.prompt || task).slice(0, 500), offer, error: String((err && (err.detail || err.message)) || err).slice(0, ERROR_MAX) });++ }++ }++ await s.db.exec("UPDATE bench_eval_runs SET status = 'ready', results = $1::jsonb, finished_at = $2::timestamptz WHERE id = $3",++ [JSON.stringify(results), s.iso(), run.id]);++ return results.length;++ }++++ async function tickEval() {++ const run = await claimEvalRun();++ if (!run) return null;++ try {++ return await generateEvalRun(run);++ } catch (err) {++ log.warn('[OpenVibe.Bench] eval run failed:', (err && err.message) || err);++ await s.db.exec("UPDATE bench_eval_runs SET status = 'failed' WHERE id = $1", [run.id]).catch(() => {});++ throw err;++ }++ }++++ /** One worker pass: one queued round, then one queued eval run. */++ async function tick() {++ if (running || stopped) return null;++ running = true;++ try {++ const round = await tickRound();++ const evalRun = await tickEval();++ return { round, evalRun };++ } finally {++ running = false;++ }++ }++++ function start() {++ if (timer || stopped || config.drill) return null;++ const run = () => tick().catch((err) => log.warn('[OpenVibe.Bench] job:', (err && err.message) || err));++ timer = setInterval(run, config.bench.intervalMs);++ if (timer.unref) timer.unref();++ run();++ return timer;++ }++++ function stop() {++ stopped = true;++ if (timer) clearInterval(timer);++ timer = null;++ }++++ return { tick, tickRound, tickEval, start, stop, claimRound, claimEvalRun, generateRound, generateEvalRun, get running() { return running; } };++}++++module.exports = { createBenchJobs };nnew file mode 100644@@@ -0,0 +1,110 @@++'use strict';++++/**++ * Generated HTML safety (kept from the Design Arena and generalised: Bench stores model output as text and never++ * puts it in its own document). Two layers:++ *++ * 1. sanitizeHtml(): strip <script>, on* attributes, javascript: URLs and navigation tags before storing.++ * 2. iframeHtml(): render only inside `<iframe sandbox srcdoc="…">` with no scripts and a CSP that blocks++ * every network fetch.++ */++++const MAX_BYTES = 200 * 1024;++const CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:";++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++const attrEscape = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++++function urlLooksLikeScript(raw) {++ const decoded = String(raw)++ .replace(/&#x([0-9a-f]+);?/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))++ .replace(/&#(\d+);?/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))++ .replace(/:?/gi, ':').replace(/&tab;?/gi, '\t').replace(/&newline;?/gi, '\n');++ return /javascript\s*:/i.test(decoded.replace(/[\s\u0000-\u001f]+/g, ''));++}++++function tagEnd(s, start) {++ let quote = null;++ for (let i = start + 1; i < s.length; i++) {++ const c = s[i];++ if (quote) { if (c === quote) quote = null; }++ else if (c === '"' || c === "'") quote = c;++ else if (c === '>') return i;++ }++ return -1;++}++++function sanitizeTag(tag) {++ let t = tag;++ t = t.replace(/([\s/])on[a-z0-9_:.-]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '$1');++ t = t.replace(/\s(on[a-z0-9_:.-]*)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '');++ t = t.replace(/(\b(?:href|src|action|formaction|xlink:href|poster|background|data)\s*=\s*)((?:"[^"]*"|'[^']*'|[^\s>]+))/gi,++ (m, name, value) => (urlLooksLikeScript(value) ? `${name}"#"` : m));++ return t;++}++++function sanitizeTags(s) {++ let out = '';++ let i = 0;++ while (i < s.length) {++ const lt = s.indexOf('<', i);++ if (lt === -1) { out += s.slice(i); break; }++ out += s.slice(i, lt);++ const next = s[lt + 1] || '';++ if (!/[a-z/]/i.test(next)) { out += '<'; i = lt + 1; continue; }++ const end = tagEnd(s, lt);++ if (end === -1) { out += s.slice(lt); break; }++ out += sanitizeTag(s.slice(lt, end + 1));++ i = end + 1;++ }++ return out;++}++++function truncateBytes(s, maxBytes) {++ if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;++ const buf = Buffer.from(s, 'utf8').subarray(0, maxBytes);++ let out = buf.toString('utf8');++ if (out.endsWith('\uFFFD')) out = out.slice(0, -1);++ return out;++}++++function sanitizeHtml(input, { maxBytes = MAX_BYTES } = {}) {++ let s = input == null ? '' : String(input);++ s = s.replace(/\u0000/g, '');++ s = truncateBytes(s, maxBytes);++ s = s.replace(/<!--[\s\S]*?-->/g, '');++ for (let i = 0; i < 4; i++) {++ const before = s;++ s = s.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, '');++ s = s.replace(/<script\b[^>]*\/\s*>/gi, '');++ if (s === before) break;++ }++ s = s.replace(/<\/?script\b[^>]*>/gi, '');++ s = s.replace(/<(\/?)(script)\b/gi, '<$1$2');++ s = s.replace(/<base\b[^>]*>/gi, '');++ s = s.replace(/<meta\b[^>]*http-equiv\s*=\s*(?:"refresh"|'refresh'|refresh)[^>]*>/gi, '');++ s = s.replace(/<\/?(?:iframe|object|embed|applet)\b[^>]*>/gi, '');++ s = sanitizeTags(s);++ s = s.replace(/j\s*a\s*v\s*a\s*s\s*c\s*r\s*i\s*p\s*t\s*:/gi, 'blocked:');++ if (/<script/i.test(s)) s = s.replace(/<(\/?)(script)/gi, '<$1$2');++ return truncateBytes(s, maxBytes);++}++++function frameDocument(html) {++ return `<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`++ + `<meta http-equiv="Content-Security-Policy" content="${CSP}">`++ + `<style>html,body{margin:0;min-height:100%;background:#fff;}img{max-width:100%;height:auto;}</style>`++ + sanitizeHtml(html);++}++++function iframeHtml({ html = '', width = 390, height = 844, title = 'A generated output', id = null, className = '' } = {}) {++ const w = Math.max(1, Math.round(Number(width) || 390));++ const h = Math.max(1, Math.round(Number(height) || 844));++ return `<iframe${id ? ` id="${esc(id)}"` : ''} class="bm-frame${className ? ` ${esc(className)}` : ''}"`++ + ` title="${esc(title)}" width="${w}" height="${h}" data-width="${w}"`++ + ` sandbox="" loading="lazy" referrerpolicy="no-referrer"`++ + ` srcdoc="${attrEscape(frameDocument(html))}"></iframe>`;++}++++module.exports = { sanitizeHtml, frameDocument, iframeHtml, truncateBytes, MAX_BYTES, CSP, attrEscape, urlLooksLikeScript };nnew file mode 100644@@@ -0,0 +1,89 @@++'use strict';++++/**++ * OpenVibe.Bench configuration. Every value comes from the environment (production: /etc/openvibe/bench.env).++ * Only environment variable NAMES appear in code and docs; secrets are never logged.++ *++ * load(env) is pure so tests can build a config without touching process.env.++ */++require('dotenv').config();++const trim = (s) => String(s || '').replace(/\/+$/, '');++const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);++++function load(env = process.env) {++ const nodeEnv = env.NODE_ENV || 'development';++ const isProduction = nodeEnv === 'production';++ const port = int(env.PORT, 5230);++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://bench.openvibe.services' : `http://localhost:${port}`));++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');++++ return {++ service: 'bench',++ name: 'OpenVibe.Bench',++ port,++ host: env.HOST || '127.0.0.1',++ nodeEnv,++ isProduction,++ baseUrl,++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,++ drill: env.BENCH_DRILL === '1' || env.BENCH_DRILL === 'true',++ limits: {++ minute: Math.max(1, int(env.BENCH_LIMITS_MINUTE, 120)),++ hour: Math.max(1, int(env.BENCH_LIMITS_HOUR, 3000)),++ },++++ // PostgreSQL (ADR-035). DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.BENCH_PGLITE_DIR || '' },++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:bench:' },++++ // OpenVibe.Network: SSO and its JWKS.++ networkUrl,++ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),++ networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),++ audience: env.BENCH_AUDIENCE || 'openvibe.bench',++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'bench',++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,++ scope: 'profile',++ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',++ },++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },++++ // OpenVibe.Events → this service's outbox delivery and ADR-033 account export/deletion.++ events: {++ secrets: String(env.BENCH_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),++ url: trim(env.BENCH_EVENTS_URL || env.EVENTS_URL || ''),++ endpoint: env.BENCH_EVENTS_ENDPOINT || '',++ intervalMs: Math.max(500, int(env.BENCH_EVENTS_INTERVAL_MS, 5000)),++ scope: env.BENCH_EVENTS_SCOPE || 'events.event.publish',++ },++++ // OpenVibe.AI: the generate operation. Bench asks Network for a client-credentials token (audience++ // openvibe.ai, scope ai.run.create; Network grants the ai.generate namespace). Generation is picked up++ // by the job loop, never by the request that creates a round.++ ai: {++ url: trim(env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700'),++ audience: env.BENCH_AI_AUDIENCE || 'openvibe.ai',++ scope: env.BENCH_AI_SCOPE || 'ai.run.create',++ waitMs: Math.max(1000, int(env.BENCH_AI_WAIT_MS, 55_000)),++ timeoutMs: Math.max(1000, int(env.BENCH_AI_TIMEOUT_MS, 120_000)),++ pollMs: Math.max(100, int(env.BENCH_AI_POLL_MS, 1500)),++ models: String(env.BENCH_MODELS || '').split(',').map((x) => x.trim()).filter(Boolean),++ maxHtmlBytes: 200 * 1024,++ },++++ // Bench budgets and jobs.++ bench: {++ roundsPerDay: Math.max(1, int(env.BENCH_ROUNDS_PER_DAY, 3)),++ roundsDaily: Math.max(1, int(env.BENCH_ROUNDS_DAILY, 20)),++ intervalMs: Math.max(250, int(env.BENCH_JOB_INTERVAL_MS, 1500)),++ pollMs: Math.max(250, int(env.BENCH_POLL_MS, 20_000)),++ maxPrompt: Math.max(20, int(env.BENCH_MAX_PROMPT, 4000)),++ evalsPerDay: Math.max(1, int(env.BENCH_EVALS_PER_DAY, 20)),++ provisionalGames: Math.max(1, int(env.BENCH_PROVISIONAL_GAMES, 10)),++ },++ };++}++++module.exports = { load };nnew file mode 100644@@@ -0,0 +1,128 @@++'use strict';++++/**++ * Bench's Contracts surface, kept as JSON in the service until the published openvibe-contracts package carries++ * the bench.* schemas and capabilities. These are the exact shapes the API, the SDK and the outbox publish, so a++ * later move into openvibe-contracts is a copy, not a redesign.++ */++++const BENCH_CAPABILITIES = Object.freeze([++ 'bench.kind.read',++ 'bench.task.create',++ 'bench.round.create',++ 'bench.round.read',++ 'bench.entry.create',++ 'bench.judgement.cast',++ 'bench.board.read',++ 'bench.dataset.read',++ 'bench.eval.manage',++]);++++const BENCH_EVENTS = Object.freeze([++ 'bench.round.finished',++ 'bench.judgement.recorded',++ 'bench.rating.changed',++]);++++const BENCH_SCHEMAS = Object.freeze({++ 'bench.kind@1': {++ type: 'object',++ required: ['id', 'room', 'entry', 'judge', 'name', 'schema', 'metrics', 'created_at'],++ properties: {++ id: { type: 'string' },++ room: { type: 'string' },++ entry: { type: 'string' },++ judge: { type: 'string' },++ name: { type: 'string' },++ schema: { type: 'object' },++ metrics: { type: 'array', items: { type: 'string' } },++ created_at: { type: 'string', format: 'date-time' },++ },++ },++ 'bench.task@1': {++ type: 'object',++ required: ['id', 'kind_id', 'prompt', 'public', 'created_by', 'created_at'],++ properties: {++ id: { type: 'string' },++ kind_id: { type: 'string' },++ prompt: { type: 'string' },++ task_class: { type: ['string', 'null'] },++ public: { type: 'boolean' },++ created_by: { type: 'string' },++ created_at: { type: 'string', format: 'date-time' },++ },++ },++ 'bench.round@1': {++ type: 'object',++ required: ['id', 'task_id', 'kind_id', 'offers', 'status', 'seed', 'created_by', 'created_at'],++ properties: {++ id: { type: 'string' },++ task_id: { type: 'string' },++ kind_id: { type: 'string' },++ offers: { type: 'array', items: { type: 'object' } },++ status: { type: 'string', enum: ['queued', 'running', 'ready', 'finished', 'failed'] },++ seed: { type: 'integer' },++ pool_fallback: { type: 'boolean' },++ receipt: { type: ['string', 'null'] },++ created_by: { type: 'string' },++ created_at: { type: 'string', format: 'date-time' },++ },++ },++ 'bench.entry@1': {++ type: 'object',++ required: ['id', 'round_id', 'provider', 'model', 'route', 'payload', 'cost_usd', 'ms', 'tokens'],++ properties: {++ id: { type: 'string' },++ round_id: { type: 'string' },++ provider: { type: 'string' },++ model: { type: 'string' },++ route: { type: 'object' },++ payload: { type: 'object' },++ cost_usd: { type: 'number' },++ ms: { type: 'number' },++ tokens: { type: 'number' },++ error: { type: ['string', 'null'] },++ ui_check: { type: ['object', 'null'] },++ run_id: { type: ['string', 'null'] },++ },++ },++ 'bench.judgement@1': {++ type: 'object',++ required: ['id', 'round_id', 'kind_id', 'judge', 'method', 'pair', 'outcome', 'score', 'at'],++ properties: {++ id: { type: 'string' },++ round_id: { type: 'string' },++ kind_id: { type: 'string' },++ judge: { type: 'string' },++ judge_name: { type: ['string', 'null'] },++ judge_version: { type: ['string', 'null'] },++ method: { type: 'string' },++ pair: { type: ['object', 'null'] },++ outcome: { type: ['string', 'null'] },++ score: { type: ['number', 'null'] },++ at: { type: 'string', format: 'date-time' },++ },++ },++ 'bench.rating@1': {++ type: 'object',++ required: ['kind_id', 'category', 'subject', 'elo', 'games', 'wins', 'losses', 'ties', 'win_rate', 'ci_lower', 'ci_upper'],++ properties: {++ kind_id: { type: 'string' },++ category: { type: 'string' },++ subject: { type: 'string' },++ elo: { type: 'number' },++ games: { type: 'number' },++ wins: { type: 'number' },++ losses: { type: 'number' },++ ties: { type: 'number' },++ win_rate: { type: 'number' },++ ci_lower: { type: 'number' },++ ci_upper: { type: 'number' },++ median_cost_usd: { type: ['number', 'null'] },++ median_ms: { type: ['number', 'null'] },++ provisional: { type: 'boolean' },++ },++ },++});++++module.exports = { BENCH_CAPABILITIES, BENCH_EVENTS, BENCH_SCHEMAS };nnew file mode 100644@@@ -0,0 +1,47 @@++'use strict';++++/**++ * OpenVibe.Bench's own PostgreSQL database (ADR-035): migrations/NNNN_*.sql, applied at boot. Nothing written++ * here is a secret; the open dataset is anonymous by design.++ */++const fs = require('fs');++const path = require('path');++const { createDb } = require('openvibe-sdk/db');++const { ids } = require('openvibe-contracts');++++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');++++async function openDb(config, { log = console, registry } = {}) {++ if (!config.db.url) {++ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL');++ const dir = config.db.pgliteDir || DEV_PGLITE;++ log.warn(`[OpenVibe.Bench] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);++ fs.mkdirSync(dir, { recursive: true });++ const db = createDb({ pglite: dir, service: 'bench', registry, log });++ await db.migrate({ dir: MIGRATIONS, log });++ return db;++ }++ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');++ const owner = createDb({ url: config.db.directUrl, service: 'bench-migrate', max: 1, log });++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++ return createDb({ url: config.db.url, service: 'bench', registry, log });++}++++/** The store every module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */++function createStore(db, { now = () => Date.now() } = {}) {++ return {++ db,++ now,++ iso: () => new Date(now()).toISOString(),++ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,++ tx: async (fn) => await db.tx((t) => fn(t)),++ close: () => db.close(),++ };++}++++async function openStore(config, { now, log } = {}) {++ return createStore(await openDb(config, { log }), { now });++}++++module.exports = { openDb, openStore, createStore, MIGRATIONS };nnew file mode 100644@@@ -0,0 +1,153 @@++'use strict';++++/**++ * The transactional outbox → OpenVibe.Events. A row is written in the same transaction as the change it announces,++ * so a retry never makes a second event. A claim lease keeps two processes from sending the same row at once.++ * Topics: bench.round.finished, bench.judgement.recorded, bench.rating.changed.++ */++const crypto = require('crypto');++const { serviceAuth } = require('openvibe-contracts');++++const MAX_ATTEMPTS_BACKOFF_MS = 5 * 60_000;++++function createPublisher({ config, s, fetchImpl = globalThis.fetch, log = console, intervalMs = config.events.intervalMs }) {++ const db = s.db;++ const worker = `bench-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;++ const configured = Boolean(config.events.url && config.oauth.clientSecret);++ const scopes = [...new Set([config.events.scope || 'events.event.publish'])];++ const clients = configured ? scopes.map((scope) => serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.events',++ scope,++ fetchImpl,++ })) : [];++ let preferred = 0;++ async function authHeaders() {++ for (let i = preferred; i < clients.length; i++) {++ try {++ const headers = await clients[i].authHeaders();++ preferred = i;++ return headers;++ } catch (err) {++ if (i === clients.length - 1) throw err;++ }++ }++ throw new Error('no Events token client is configured');++ }++ let timer = null;++ let running = false;++ const stats = { published: 0, failed: 0, last_error: null };++++ function backoffMs(attempts) {++ return Math.min(MAX_ATTEMPTS_BACKOFF_MS, 5000 * Math.pow(2, Math.max(0, Math.min(6, attempts))));++ }++++ async function claim(limit = 20) {++ const now = new Date(s.now()).toISOString();++ const stale = new Date(s.now() - 2 * 60_000).toISOString();++ return await db.many(++ `UPDATE bench_outbox o SET claimed_at = $1::timestamptz, claimed_by = $2++ WHERE o.event_id IN (++ SELECT o2.event_id FROM bench_outbox o2++ WHERE o2.published_at IS NULL AND o2.next_attempt_at <= $1::timestamptz++ AND (o2.claimed_at IS NULL OR o2.claimed_at < $3::timestamptz)++ ORDER BY o2.created_at ASC, o2.event_id ASC++ LIMIT $4++ FOR UPDATE SKIP LOCKED++ )++ RETURNING o.*`,++ [now, worker, stale, Math.min(100, Math.max(1, limit))],++ );++ }++++ function envelope(row) {++ const subjectId = row.payload && (row.payload.subject || row.payload.owner);++ return {++ event_id: row.event_id,++ event_type: row.topic,++ version: 1,++ source: 'bench',++ actor: { type: 'service', id: 'bench' },++ subject: subjectId && String(subjectId).startsWith('usr_') ? { type: 'user', id: subjectId } : { type: 'service', id: 'bench' },++ timestamp: new Date(s.now()).toISOString(),++ priority: 'important',++ visibility: 'internal',++ payload: row.payload,++ };++ }++++ async function send(row) {++ const headers = { 'Content-Type': 'application/json', Accept: 'application/json', ...(await authHeaders()) };++ const res = await fetchImpl(`${config.events.url.replace(/\/+$/, '')}/api/v1/events`, {++ method: 'POST',++ headers,++ body: JSON.stringify(envelope(row)),++ signal: AbortSignal.timeout(15_000),++ });++ if (!res.ok) {++ const text = await res.text().catch(() => '');++ throw new Error(`events ${res.status}${text ? `: ${text.slice(0, 300)}` : ''}`);++ }++ await db.exec('UPDATE bench_outbox SET published_at = $1::timestamptz, claimed_at = NULL, claimed_by = NULL, last_error = NULL WHERE event_id = $2',++ [new Date(s.now()).toISOString(), row.event_id]);++ return { ok: true };++ }++++ async function runOnce({ limit = 20 } = {}) {++ if (!configured) return { configured: false, claimed: 0, published: 0, failed: 0 };++ if (running) return { configured: true, busy: true, claimed: 0, published: 0, failed: 0 };++ running = true;++ let claimed = 0;++ let published = 0;++ let failed = 0;++ try {++ const rows = await claim(limit);++ claimed = rows.length;++ for (const row of rows) {++ try {++ await send(row);++ published++;++ stats.published++;++ } catch (err) {++ failed++;++ stats.failed++;++ const message = String((err && err.message) || err).slice(0, 500);++ stats.last_error = message;++ const attempts = Number(row.attempts) + 1;++ const next = new Date(s.now() + backoffMs(attempts)).toISOString();++ await db.exec(++ `UPDATE bench_outbox SET attempts = attempts + 1, last_error = $1, claimed_at = NULL, claimed_by = NULL, next_attempt_at = $2::timestamptz WHERE event_id = $3`,++ [message, next, row.event_id],++ );++ log.warn(`[OpenVibe.Bench] outbox ${row.event_id} publish failed (attempt ${attempts}): ${message}`);++ }++ }++ return { configured: true, claimed, published, failed };++ } finally {++ running = false;++ }++ }++++ function start() {++ if (!configured || timer || config.drill) return false;++ timer = setInterval(() => { runOnce().catch((err) => log.warn('[OpenVibe.Bench] outbox tick failed:', err.message)); }, intervalMs);++ if (timer.unref) timer.unref();++ return true;++ }++++ function stop() {++ if (timer) clearInterval(timer);++ timer = null;++ return Promise.resolve();++ }++++ async function pending() {++ return Number(await db.value('SELECT COUNT(*)::int AS n FROM bench_outbox WHERE published_at IS NULL')) || 0;++ }++++ return { configured, runOnce, start, stop, pending, stats: () => ({ ...stats }) };++}++++module.exports = { createPublisher, MAX_ATTEMPTS_BACKOFF_MS };nnew file mode 100644@@@ -0,0 +1,264 @@++'use strict';++++/**++ * /api/v1 — OpenVibe.Bench's JSON API.++ *++ * GET /kinds++ * GET /kinds/:kindId++ * POST /kinds/:kindId/tasks++ * GET /tasks/:id++ * POST /tasks/:id/rounds++ * GET /rounds/:id++ * POST /rounds/:id/entries (a room submits outside entries with a service token)++ * GET /judgements/next++ * POST /rounds/:id/judgements++ * GET /boards/:kindId?type=elo|pareto|cost++ * GET /models/:model++ * GET /datasets/:kindId.jsonl++ * POST /evals++ * GET /evals/:id++ * POST /evals/:id/run++ * GET /evals/:id/results++ *++ * A person is the bench_at session cookie or a Network user token as a Bearer; a service sends a client-credentials++ * token with bench.* capabilities. A session write must come from bench.openvibe.services itself.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { asyncRouter } = require('./router');++const { sameOrigin } = require('./same-origin');++const { kindById } = require('../kinds');++const { BenchError } = require('../store');++++function createApi(ctx) {++ const { config, s, principal, bench } = ctx;++ const r = asyncRouter();++++ r.use(express.json({ limit: '64kb' }));++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++ r.use(principal.middleware);++++ const problem = (res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: res.req && res.req.ov });++ const invalid = (res, detail) => problem(res, 400, 'bench.invalid', detail);++ const failed = (res, err) => {++ if (err instanceof BenchError) return problem(res, err.status, err.code, err.detail);++ ctx.log.warn('[OpenVibe.Bench] API failed:', (err && err.message) || err);++ return problem(res, 500, 'internal.error', 'Bench could not answer. Try again in a moment.');++ };++++ const subjectOf = (req) => (req.principal && req.principal.kind === 'user' ? req.principal.subject : null);++++ /** A person (any signed-in user) or a service token with the named capability. */++ function actor(cap) {++ return (req, res, next) => {++ const p = req.principal;++ if (p.kind === 'app') {++ if (p.hasCapability(cap)) return next();++ return problem(res, 403, 'bench.capability_denied', `A service token needs ${cap}.`);++ }++ if (p.kind === 'user') {++ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config)) {++ return problem(res, 403, 'bench.cross_site', 'A signed-in request that changes something must come from bench.openvibe.services itself.');++ }++ return next();++ }++ return problem(res, 401, 'bench.sign_in_required', 'Sign in at bench.openvibe.services, or send a service token.');++ };++ }++++ const requirePerson = principal.requirePerson;++++ // ── Kinds ───────────────────────────────────────────────++ r.get('/kinds', async (req, res) => {++ const list = await bench.listKinds();++ res.set('Cache-Control', 'public, max-age=300');++ return res.json({ count: list.length, kinds: list });++ });++++ r.get('/kinds/:kindId', async (req, res) => {++ const kind = await bench.getKind(String(req.params.kindId || ''));++ if (!kind) return problem(res, 404, 'bench.kind_not_found', 'No such kind.');++ res.set('Cache-Control', 'public, max-age=300');++ return res.json({ kind });++ });++++ // ── Tasks ───────────────────────────────────────────────++ r.post('/kinds/:kindId/tasks', actor('bench.task.create'), async (req, res) => {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ const owner = subjectOf(req) || req.principal.requester;++ const out = await bench.createTask({++ kindId: String(req.params.kindId || ''),++ prompt: b.prompt,++ taskClass: b.task_class == null ? null : String(b.task_class),++ public: Boolean(b.public),++ subject: owner,++ });++ if (!out.ok) return problem(res, out.status || 400, out.code || 'bench.invalid', out.message || 'invalid task');++ res.set('Location', `/api/v1/tasks/${out.task.id}`);++ return res.status(201).json({ task: out.task });++ });++++ r.get('/tasks/:id', async (req, res) => {++ const task = await bench.getTask(String(req.params.id || ''));++ if (!task) return problem(res, 404, 'bench.task_not_found', 'No such task.');++ res.set('Cache-Control', 'public, max-age=60');++ return res.json({ task });++ });++++ // ── Rounds ──────────────────────────────────────────────++ r.post('/tasks/:id/rounds', actor('bench.round.create'), async (req, res) => {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ const owner = subjectOf(req) || req.principal.requester;++ try {++ const out = await bench.createRound({++ taskId: String(req.params.id || ''),++ offers: b.offers,++ seed: b.seed,++ subject: owner,++ });++ if (!out.ok) return problem(res, out.status || 400, out.code || 'bench.invalid', out.message || 'invalid round');++ res.set('Location', `/api/v1/rounds/${out.round.id}`);++ return res.status(201).json({ round: out.round });++ } catch (err) { return failed(res, err); }++ });++++ r.get('/rounds/:id', async (req, res) => {++ try {++ const round = await bench.getRound(String(req.params.id || ''), { viewerSubject: subjectOf(req) });++ if (!round) return problem(res, 404, 'bench.round_not_found', 'No such round.');++ res.set('Cache-Control', round.blind ? 'private, no-store' : 'public, max-age=60');++ return res.json({ round });++ } catch (err) { return failed(res, err); }++ });++++ r.post('/rounds/:id/entries', principal.requireCapability('bench.entry.create'), async (req, res) => {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ try {++ const entry = await bench.addEntry({++ roundId: String(req.params.id || ''),++ provider: b.provider,++ model: b.model,++ route: b.route,++ payload: b.payload,++ costUsd: b.cost_usd,++ ms: b.ms,++ tokens: b.tokens,++ error: b.error,++ uiCheck: b.ui_check,++ runId: b.run_id,++ });++ res.set('Location', `/api/v1/rounds/${String(req.params.id)}`);++ return res.status(201).json({ entry });++ } catch (err) { return failed(res, err); }++ });++++ // ── Judgements ──────────────────────────────────────────++ r.get('/judgements/next', requirePerson, async (req, res) => {++ try {++ const pair = await bench.nextJudgement({ kind: String(req.query.kind || ''), subject: subjectOf(req), random: ctx.random || Math.random });++ res.set('Cache-Control', 'private, no-store');++ if (!pair) return res.json({ pair: null, detail: 'There is no round waiting for your judgement right now.' });++ return res.json(pair);++ } catch (err) { return failed(res, err); }++ });++++ r.post('/rounds/:id/judgements', async (req, res) => {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ const roundId = String(req.params.id || '');++ let judge = typeof b.judge === 'string' && b.judge ? b.judge : null;++ if (!judge) {++ const round = await bench.getRound(roundId, { viewerSubject: null });++ if (!round) return problem(res, 404, 'bench.round_not_found', 'No such round.');++ judge = (kindById(round.kind_id) || {}).judge || null;++ }++ if (!judge) return invalid(res, 'judge is required');++ const isHuman = judge === 'human_pairwise' || judge === 'rubric';++ const guard = isHuman ? requirePerson : principal.requireCapability('bench.judgement.cast');++ return guard(req, res, async () => {++ try {++ const subject = isHuman ? subjectOf(req) : req.principal.requester;++ const out = await bench.castJudgement({++ roundId, kindId: b.kind_id, judge,++ judgeName: b.judge_name, judgeVersion: b.judge_version,++ pair: b.pair, outcome: b.outcome, score: b.score, subject,++ });++ res.set('Cache-Control', 'private, no-store');++ return res.status(201).json(out);++ } catch (err) { return failed(res, err); }++ });++ });++++ // ── Boards, models, datasets ────────────────────────────++ r.get('/boards/:kindId', async (req, res) => {++ try {++ const type = typeof req.query.type === 'string' && req.query.type ? req.query.type : 'elo';++ const board = await bench.board(String(req.params.kindId || ''), {++ type,++ category: typeof req.query.category === 'string' ? req.query.category : 'all',++ period: typeof req.query.period === 'string' ? req.query.period : 'all',++ });++ if (!board) return problem(res, 404, 'bench.kind_not_found', 'No such kind.');++ res.set('Cache-Control', 'public, max-age=30');++ return res.json({ board });++ } catch (err) { return failed(res, err); }++ });++++ r.get('/models/:model', async (req, res) => {++ try {++ const out = await bench.modelSummary(String(req.params.model || ''));++ if (!out) return problem(res, 404, 'bench.model_not_found', 'No such model.');++ res.set('Cache-Control', 'public, max-age=60');++ return res.json(out);++ } catch (err) { return failed(res, err); }++ });++++ r.get('/datasets/:kindId.jsonl', async (req, res) => {++ try {++ const text = await bench.datasetJsonl(String(req.params.kindId || ''));++ if (text == null) return problem(res, 404, 'bench.kind_not_found', 'No such kind.');++ res.set('Cache-Control', 'public, max-age=60');++ res.set('Content-Disposition', `attachment; filename="${String(req.params.kindId || 'bench')}-dataset.jsonl"`);++ return res.type('application/x-ndjson').send(text);++ } catch (err) { return failed(res, err); }++ });++++ // ── Private evals ───────────────────────────────────────++ r.post('/evals', actor('bench.eval.manage'), async (req, res) => {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ const owner = subjectOf(req) || req.principal.requester;++ const out = await bench.createEval({ owner, kindId: b.kind_id, name: b.name, testSuite: b.test_suite });++ if (!out.ok) return problem(res, out.status || 400, out.code || 'bench.invalid', out.message || 'invalid eval');++ res.set('Location', `/api/v1/evals/${out.eval.id}`);++ return res.status(201).json({ eval: out.eval });++ });++++ r.get('/evals/:id', actor('bench.eval.manage'), async (req, res) => {++ const owner = subjectOf(req) || req.principal.requester;++ const ev = await bench.getEval(String(req.params.id || ''), { owner });++ if (!ev) return problem(res, 404, 'bench.eval_not_found', 'No such eval (or it is not yours).');++ res.set('Cache-Control', 'private, no-store');++ return res.json({ eval: ev });++ });++++ r.post('/evals/:id/run', actor('bench.eval.manage'), async (req, res) => {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ const owner = subjectOf(req) || req.principal.requester;++ try {++ const run = await bench.runEval({ evalId: String(req.params.id || ''), offers: b.offers, owner });++ res.set('Cache-Control', 'private, no-store');++ return res.status(202).json({ run });++ } catch (err) { return failed(res, err); }++ });++++ r.get('/evals/:id/results', actor('bench.eval.manage'), async (req, res) => {++ const owner = subjectOf(req) || req.principal.requester;++ const out = await bench.evalResults(String(req.params.id || ''), { owner });++ if (!out) return problem(res, 404, 'bench.eval_not_found', 'No such eval (or it is not yours).');++ res.set('Cache-Control', 'private, no-store');++ return res.json(out);++ });++++ return r;++}++++module.exports = { createApi };nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++++/**++ * Crawl artifacts for bench.openvibe.services. Sign-in, the API and the internal route are not for crawlers.++ */++const fs = require('fs');++const path = require('path');++const seo = require('openvibe-shared/seo');++const cache = require('openvibe-shared/cache-policy');++const { asyncRouter } = require('./router');++++const SITE_NAME = 'OpenVibe.Bench';++const DESCRIPTION = 'OpenVibe.Bench is one AI benchmark and comparison engine for every room: blind votes, rubrics, graders, test suites and game outcomes, with reproducible pinned rounds and an open dataset per kind.';++const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics', '/mcp'];++++function dayOf(ts) {++ const m = String(ts == null ? '' : ts).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 homeJsonLd(config) {++ const site = String(config.baseUrl).replace(/\/+$/, '');++ return [++ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),++ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),++ ];++}++++const PAGES = [++ { path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Bench home', text: 'One benchmark engine for every room: the kinds, boards and open dataset.' },++ { path: '/docs', changefreq: 'weekly', priority: 0.6, title: 'The Bench API', text: 'The Bench JSON API, boards, dataset and MCP tools.' },++];++++function createDiscoveryRoutes(ctx) {++ const { config } = ctx;++ const r = asyncRouter();++ const site = String(config.baseUrl).replace(/\/+$/, '');++ const abs = (p) => `${site}${p}`;++ const TEXT = cache.htmlHeaders({ maxAge: 3600 });++++ r.get('/robots.txt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', TEXT).send(++ '# bench.openvibe.services: boards and datasets are JSON; sign-in, the API and the internal route are not for crawlers.\n'++ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));++ });++++ r.get('/llms.txt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({++ name: SITE_NAME,++ summary: DESCRIPTION,++ details: 'GET /api/v1/kinds lists the fifteen benchmark kinds. GET /api/v1/boards/:kind?type=elo|pareto|cost renders a board as JSON. GET /api/v1/datasets/:kind.jsonl is the open dataset without voter ids. POST /mcp is the Model Context Protocol endpoint.',++ sections: [++ { title: 'Start here', links: [{ title: SITE_NAME, url: abs('/') }, { title: 'The API', url: abs('/docs') }] },++ { title: 'Machine-readable', links: [{ title: 'Kinds (JSON)', url: abs('/api/v1/kinds') }, { title: 'Sitemap', url: abs('/sitemap.xml') }, { title: 'Release metadata (JSON)', url: abs('/release.json') }] },++ ],++ }));++ });++++ r.get('/llms-full.txt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({++ site: SITE_NAME,++ summary: 'The Bench home and its API docs.',++ base: site,++ maxBytes: 256 * 1024,++ sections: [{ title: 'Pages', pages: PAGES.map((p) => ({ url: p.path, title: p.title, text: p.text })) }],++ }));++ });++++ r.get('/sitemap.xml', (_req, res) => {++ const lastmod = siteUpdated();++ const urls = PAGES.map((p) => ({ loc: abs(p.path), ...(lastmod ? { lastmod } : {}), changefreq: p.changefreq, priority: p.priority }));++ res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));++ });++++ return r;++}++++module.exports = { createDiscoveryRoutes, homeJsonLd, PAGES, DESCRIPTION, SITE_NAME };nnew file mode 100644@@@ -0,0 +1,47 @@++'use strict';++++/**++ * Bench's server-rendered pages: a plain home and the API docs. Public boards are JSON only (Report renders their++ * HTML later).++ */++const express = require('express');++const seo = require('openvibe-shared/seo');++const { asyncRouter } = require('./router');++const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');++const { raw } = require('../render/html');++const { send } = require('../render/layout');++const views = require('../views');++++const PUBLIC_CACHE = 'public, max-age=60';++++function createPageRoutes(ctx) {++ const { config, bench } = ctx;++ const r = asyncRouter();++ const base = () => String(config.baseUrl).replace(/\/+$/, '');++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });++++ r.get('/', async (req, res) => {++ const kinds = await bench.listKinds();++ return page(req, res, {++ index: true, cache: PUBLIC_CACHE,++ title: null,++ description: 'OpenVibe.Bench is one AI benchmark and comparison engine for every room: blind votes, rubrics, graders, test suites and game outcomes, with reproducible pinned rounds and an open dataset per kind.',++ jsonLd: homeJsonLd(config),++ body: raw(views.home({ kinds, counts: { kinds: kinds.length } })),++ });++ });++++ r.get('/docs', (req, res) => page(req, res, {++ index: true, cache: PUBLIC_CACHE,++ title: 'The Bench API',++ description: 'The Bench JSON API, the boards, the open dataset and the MCP tools.',++ jsonLd: [seo.jsonLd.webPage({ name: 'The Bench API', url: `${base()}/docs`, description: 'The Bench JSON API, the boards, the open dataset and the MCP tools.', siteUrl: base() })],++ body: raw(views.docs()),++ }));++++ r.use(createDiscoveryRoutes(ctx));++++ return r;++}++++module.exports = { createPageRoutes };nnew file mode 100644@@@ -0,0 +1,106 @@++'use strict';++++/**++ * Who is calling /api/v1 — req.principal:++ *++ * { kind: 'user', subject: 'usr_…', username, role, token, viaSession }++ * a person: their Network user token as a Bearer, or this site's session cookie (bench_at).++ * { kind: 'app', requester: 'service:…' | 'app:…' | 'agent:…', project, claims, capabilities }++ * a Network service token for audience openvibe.bench with bench.* capabilities.++ * { kind: 'anonymous' }++ *++ * A request that presents a token is judged on that token alone. A session-cookie write must come from this site++ * (Sec-Fetch-Site same-origin, or an Origin equal to ours).++ */++const { serviceAuth, http, ids } = require('openvibe-contracts');++const { BENCH_CAPABILITIES } = require('../contracts');++const { sameOrigin } = require('./same-origin');++++const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++++const CAPABILITIES = Object.freeze([...BENCH_CAPABILITIES]);++++function decodePayload(token) {++ const parts = String(token || '').split('.');++ if (parts.length !== 3) return null;++ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }++}++++function roleClaim(claims) {++ return typeof claims.role === 'string' && claims.role ? claims.role.slice(0, 32) : 'user';++}++function usernameClaim(claims) {++ return typeof claims.username === 'string' ? claims.username.slice(0, 64) : null;++}++++function requesterOfSub(sub) {++ if (sub.startsWith('svc:')) return `service:${sub.slice(4)}`;++ return sub;++}++++function createPrincipal({ config, keys }) {++ async function resolve(req) {++ const header = String(req.headers.authorization || '');++ if (header.startsWith('Bearer ')) {++ const token = header.slice(7).trim();++ const payload = decodePayload(token);++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {++ const publicKey = await keys.pemForToken(token);++ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });++ if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };++ const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;++ const capabilities = Array.isArray(r.claims.cap) ? r.claims.cap : [];++ return {++ principal: {++ kind: 'app', requester: requesterOfSub(r.claims.sub), project, claims: r.claims,++ capabilities, hasCapability: (cap) => capabilities.includes(cap),++ },++ };++ }++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience });++ if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };++ if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };++ if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };++ return { principal: { kind: 'user', subject: v.claims.subject_id, requester: `user:${v.claims.subject_id}`, project: null, role: roleClaim(v.claims), username: usernameClaim(v.claims), token, viaSession: false } };++ }++ const viewer = req.viewer;++ if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {++ return { principal: { kind: 'user', subject: viewer.subject, requester: `user:${viewer.subject}`, project: null, role: viewer.role || 'user', username: viewer.username || null, token: viewer.token, viaSession: true } };++ }++ return { principal: { kind: 'anonymous' } };++ }++++ async function middleware(req, res, next) {++ const r = await resolve(req);++ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });++ req.principal = r.principal;++ return next();++ }++++ function requireCapability(cap) {++ if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.Bench's capabilities`);++ return function guard(req, res, next) {++ const p = req.principal;++ if (p.kind === 'app' && p.hasCapability(cap)) return next();++ if (p.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Send a person\'s Network token as a Bearer, or a service token with bench.* capabilities.', ctx: req.ov });++ if (p.kind === 'user') return http.sendProblem(res, 403, 'bench.capability_required', { detail: `This route needs the ${cap} capability on a service token.`, ctx: req.ov });++ return http.sendProblem(res, 403, 'bench.capability_required', { detail: `This route needs the ${cap} capability.`, ctx: req.ov });++ };++ }++++ function requirePerson(req, res, next) {++ const p = req.principal;++ if (p.kind === 'anonymous') return http.sendProblem(res, 401, 'bench.sign_in_required', { detail: 'Sign in at bench.openvibe.services, or send a person\'s Network token as a Bearer.', ctx: req.ov });++ if (p.kind !== 'user') return http.sendProblem(res, 403, 'bench.person_required', { detail: 'This route is for a person, not a service token.', ctx: req.ov });++ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config)) {++ return http.sendProblem(res, 403, 'bench.cross_site', { detail: 'A signed-in request that changes something must come from bench.openvibe.services itself.', ctx: req.ov });++ }++ return next();++ }++++ return { middleware, resolve, requireCapability, requirePerson };++}++++module.exports = { createPrincipal, CAPABILITIES, decodePayload };nnew file mode 100644@@@ -0,0 +1,22 @@++'use strict';++++/**++ * express.Router() whose get/post/patch/delete handlers may be async: a rejected promise goes to next(err).++ */++const express = require('express');++++function asyncRouter() {++ const r = express.Router();++ for (const method of ['get', 'post', 'patch', 'delete', 'put', 'all']) {++ const orig = r[method].bind(r);++ r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {++ try {++ const out = h(req, res, next);++ if (out && typeof out.catch === 'function') out.catch(next);++ } catch (err) { next(err); }++ })));++ }++ return r;++}++++module.exports = { asyncRouter };nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++++/**++ * Same-origin writes (CSRF). A write authenticated by the httpOnly `bench_at` session cookie must come from this++ * site: Sec-Fetch-Site: same-origin, or an Origin equal to BASE_URL's origin. A Bearer write is not++ * cookie-authenticated and is unaffected.++ */++const { http } = require('openvibe-contracts');++const { ACCESS_COOKIE } = require('../auth/sso');++++function ownOrigin(config) {++ try { return new URL(config.baseUrl).origin; } catch { return null; }++}++++function bearer(req) {++ const h = String(req.get('authorization') || '');++ return /^Bearer\s+\S/i.test(h) ? h.replace(/^Bearer\s+/i, '').trim() : null;++}++++function cookieAuthenticated(req) {++ return Boolean(req.cookies && req.cookies[ACCESS_COOKIE]);++}++++function sameOrigin(req, config) {++ const site = String(req.get('sec-fetch-site') || '').trim().toLowerCase();++ if (site === 'same-origin') return true;++ const sent = String(req.get('origin') || '');++ const own = ownOrigin(config);++ return Boolean(sent) && Boolean(own) && sent === own;++}++++function writeAllowed(req, config) {++ if (bearer(req)) return true;++ if (!cookieAuthenticated(req)) return true;++ return sameOrigin(req, config);++}++++function requireSameOrigin(config, { detail = 'A change must come from this site itself.', code = 'bench.cross_site' } = {}) {++ return (req, res, next) => {++ if (writeAllowed(req, config)) return next();++ return http.sendProblem(res, 403, code, { detail, ctx: req.ov });++ };++}++++module.exports = { ownOrigin, bearer, cookieAuthenticated, sameOrigin, writeAllowed, requireSameOrigin };nnew file mode 100644@@@ -0,0 +1,27 @@++'use strict';++++/**++ * Account export and deletion → Bench (ADR-033; openvibe-sdk/account-data). The rows a person owns are the tasks++ * and rounds they started, the judgements they cast and the private evals they own. Ratings are aggregate and name++ * no person, so they stay; a public dataset judgement never carries a voter id.++ */++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++++const TABLES = [++ { table: 'bench_judgements', subject: 'subject', file: 'bench_judgements.json', erase: { anonymize: {} } },++ { table: 'bench_rounds', subject: 'created_by', file: 'bench_rounds.json', erase: 'delete' },++ { table: 'bench_tasks', subject: 'created_by', file: 'bench_tasks.json', erase: 'delete' },++ { table: 'bench_evals', subject: 'owner', file: 'bench_evals.json', erase: 'delete' },++];++++function create({ db, log = console } = {}) {++ return createAccountData({++ db,++ service: 'bench',++ tables: TABLES,++ note: 'Bench exports your tasks, rounds, judgements and private evals; the public dataset is anonymous and a rating never names a person.',++ log,++ });++}++++module.exports = { create, TABLES, TOPICS };nnew file mode 100644@@@ -0,0 +1,17 @@++'use strict';++++/**++ * Who is staff at OpenVibe.Bench: the Network role claim on a person's session. contracts.staff.atLeast is the one++ * place the ranking lives.++ */++const { staff } = require('openvibe-contracts');++++const STAFF_MIN = 'global_mod';++++function isStaff(who) {++ if (!who || typeof who !== 'object' || who.kind !== 'user') return false;++ if (typeof who.role !== 'string' || !who.role) return false;++ try { return staff.atLeast(who.role, STAFF_MIN); } catch { return false; }++}++++module.exports = { isStaff, STAFF_MIN };nnew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++++/**++ * OpenVibe.Bench — process entry. `node server/index.js` listens on PORT (5230) behind nginx (deploy/).++ */++const { createApp } = require('./app');++const { gracefulStop } = require('openvibe-sdk/service');++const { startTelemetryRetention } = require('openvibe-sdk/telemetry');++const { startSubscriptions } = require('openvibe-sdk/account-data');++++function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {++ return gracefulStop({++ name: 'OpenVibe.Bench', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,++ close: [++ () => { for (const t of timers) clearInterval(t); },++ () => ctx.keys.client.stop(),++ () => ctx.jobs.stop(),++ () => ctx.publisher.stop(),++ async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },++ () => ctx.s.close(),++ ...extra,++ ],++ });++}++++async function start() {++ const { app, ctx } = await createApp();++ const { config } = ctx;++++ const server = app.listen(config.port, config.host, () => {++ console.log(`[OpenVibe.Bench] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.s.db.store})`);++ ctx.keys.ensure().catch(() => {});++ });++ server.keepAliveTimeout = 65_000;++ ctx.retention = startTelemetryRetention({ db: ctx.s.db, log: ctx.log || console, olderThanDays: 14 });++ ctx.telemetryTick = setInterval(() => { ctx.telemetry.flush().catch(() => {}); }, 60_000);++ ctx.telemetryTick.unref();++ ctx.keys.client.start();++++ // Jobs: rounds and private eval runs. Off under BENCH_DRILL; tests call tick().++ ctx.jobs.start();++ // Outbox publisher → OpenVibe.Events.++ ctx.publisher.start();++++ const subs = config.drill ? null : startSubscriptions({++ eventsUrl: config.events.url,++ endpoint: config.events.endpoint || `http://127.0.0.1:${config.port}/internal/events`,++ secret: config.events.secrets[0] || '',++ networkInternalUrl: config.networkInternalUrl,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ log: ctx.log || console,++ });++ const extra = [() => { if (subs) subs.stop(); }];++ createLifecycle({ server, ctx, extra });++ return { server, ctx };++}++++if (require.main === module) {++ start().catch((err) => { console.error('[OpenVibe.Bench] failed to start:', err); process.exit(1); });++}++++module.exports = { start, createLifecycle };nnew file mode 100644@@@ -0,0 +1,34 @@++'use strict';++++/**++ * The fifteen kinds Bench launches with (plan 2026-10-28 §4.12). Each names the room that plugs into it, the++ * entry payload type the room/AI produces, and the judge that ranks entries. A kind's `schema` is the JSON shape++ * of an entry payload and its `metrics` are the axes a board may show.++ */++++const JUDGES = Object.freeze(['human_pairwise', 'rubric', 'automatic_grader', 'test_suite', 'game_outcome']);++++/** Pairwise winners as the blind voter saw them; both_bad is a game for neither. */++const WINNERS = Object.freeze(['left', 'right', 'tie', 'both_bad']);++++const KINDS = Object.freeze([++ { id: 'design.page', room: 'design', entry: 'html_page', judge: 'human_pairwise', name: 'Design page arena', metrics: ['elo', 'cost_usd', 'ms'], schema: { entry: 'html_page', payload: { html: 'string' } } },++ { id: 'design.component', room: 'design', entry: 'html_page', judge: 'human_pairwise', name: 'Design component arena', metrics: ['elo', 'cost_usd', 'ms'], schema: { entry: 'html_page', payload: { html: 'string' } } },++ { id: 'games.match', room: 'games', entry: 'game_match', judge: 'game_outcome', name: 'AI vs AI games', metrics: ['cost_usd', 'ms'], schema: { entry: 'game_match', payload: { result: 'object' } } },++ { id: 'games.fun', room: 'games', entry: 'game_match', judge: 'human_pairwise', name: 'Most fun play', metrics: ['elo', 'cost_usd'], schema: { entry: 'game_match', payload: { result: 'object' } } },++ { id: 'meme.caption', room: 'meme', entry: 'image', judge: 'human_pairwise', name: 'Meme caption arena', metrics: ['elo', 'cost_usd'], schema: { entry: 'image', payload: { image: 'string', caption: 'string' } } },++ { id: 'vision.answer', room: 'vision', entry: 'model_answer', judge: 'automatic_grader', name: 'Vision answers', metrics: ['pass_rate', 'cost_usd'], schema: { entry: 'model_answer', payload: { answer: 'string' } } },++ { id: 'vision.pair', room: 'vision', entry: 'model_answer', judge: 'human_pairwise', name: 'Vision head-to-head', metrics: ['elo', 'cost_usd'], schema: { entry: 'model_answer', payload: { answer: 'string' } } },++ { id: 'codes.patch', room: 'codes', entry: 'code_patch', judge: 'test_suite', name: 'Code patches', metrics: ['pass_rate', 'cost_usd'], schema: { entry: 'code_patch', payload: { patch: 'string' } } },++ { id: 'codes.review', room: 'codes', entry: 'code_patch', judge: 'human_pairwise', name: 'Code diff review', metrics: ['elo', 'cost_usd'], schema: { entry: 'code_patch', payload: { patch: 'string' } } },++ { id: 'surf.desk', room: 'surf', entry: 'agent_on_desk', judge: 'human_pairwise', name: 'Agents on desks', metrics: ['elo', 'cost_usd', 'ms'], schema: { entry: 'agent_on_desk', payload: { desk: 'object' } } },++ { id: 'chat.reply', room: 'chat', entry: 'conversation_turn', judge: 'human_pairwise', name: 'Chat replies', metrics: ['elo', 'cost_usd'], schema: { entry: 'conversation_turn', payload: { reply: 'string' } } },++ { id: 'speech.tts', room: 'speech', entry: 'audio', judge: 'human_pairwise', name: 'Text to speech voices', metrics: ['elo', 'cost_usd'], schema: { entry: 'audio', payload: { audio_url: 'string' } } },++ { id: 'speech.asr', room: 'speech', entry: 'transcript', judge: 'automatic_grader', name: 'Speech recognition', metrics: ['word_error_rate', 'cost_usd'], schema: { entry: 'transcript', payload: { transcript: 'string' } } },++ { id: 'translate.ja', room: 'translate', entry: 'translation', judge: 'human_pairwise', name: 'Japanese translation', metrics: ['elo', 'cost_usd'], schema: { entry: 'translation', payload: { translation: 'string' } } },++ { id: 'search.answer', room: 'search', entry: 'cited_answer', judge: 'human_pairwise', name: 'Cited answers', metrics: ['elo', 'cost_usd'], schema: { entry: 'cited_answer', payload: { answer: 'string', citations: 'array' } } },++]);++++const byId = new Map(KINDS.map((k) => [k.id, k]));++++module.exports = { KINDS, JUDGES, WINNERS, kindById: (id) => byId.get(id) || null };nnew file mode 100644@@@ -0,0 +1,229 @@++'use strict';++++/**++ * Bench as a Model Context Protocol server (Streamable HTTP), POST /mcp — modelled on OpenVibe.Search's endpoint.++ * Stateless JSON-RPC 2.0: initialize, ping, tools/list and tools/call, single requests or batches, CORS preflight,++ * and a JSON-RPC parse error for malformed bodies.++ *++ * The six tools are the §4.12 list. A person is a Network user token as a Bearer (or the bench_at session cookie).++ */++++const express = require('express');++const pkg = require('../package.json');++++const PROTOCOL_VERSION = '2025-06-18';++const SUPPORTED_VERSIONS = Object.freeze(new Set(['2025-06-18', '2025-03-26']));++const BODY_LIMIT = '64kb';++++const INSTRUCTIONS = 'Bench runs one shared benchmark engine across every OpenVibe room. list_kinds shows the fifteen kinds; start_round queues a pinned round and returns its id; next_pair fetches a blind pair to judge; cast_judgement records a vote and reveals the two entries; get_board renders an elo, pareto or cost board; run_eval runs a private task suite.';++++function toolsInput() {++ return {++ list_kinds: { type: 'object', properties: {} },++ start_round: {++ type: 'object',++ required: ['kind', 'prompt'],++ properties: {++ kind: { type: 'string', description: 'A kind id, e.g. design.page.' },++ prompt: { type: 'string', description: 'The task prompt.' },++ offers: { type: 'array', items: { type: 'object' }, description: 'Optional pinned {provider, model} slots.' },++ seed: { type: 'integer' },++ },++ },++ next_pair: {++ type: 'object',++ required: ['kind'],++ properties: { kind: { type: 'string' }, judge: { type: 'string' } },++ },++ cast_judgement: {++ type: 'object',++ required: ['round', 'left', 'right', 'outcome'],++ properties: {++ round: { type: 'string' },++ left: { type: 'string' },++ right: { type: 'string' },++ outcome: { type: 'string', enum: ['left', 'right', 'tie', 'both_bad'] },++ },++ },++ get_board: {++ type: 'object',++ required: ['kind'],++ properties: {++ kind: { type: 'string' },++ type: { type: 'string', enum: ['elo', 'pareto', 'cost'], default: 'elo' },++ category: { type: 'string' },++ period: { type: 'string' },++ },++ },++ run_eval: {++ type: 'object',++ required: ['kind', 'test_suite'],++ properties: {++ kind: { type: 'string' },++ test_suite: { type: 'array', items: { type: 'object' } },++ offers: { type: 'array', items: { type: 'object' } },++ },++ },++ };++}++++const TOOL_META = Object.freeze([++ { name: 'bench.list_kinds', title: 'List Bench kinds', description: 'List the fifteen benchmark kinds Bench runs for every room.' },++ { name: 'bench.start_round', title: 'Start a Bench round', description: 'Create a task and queue a pinned round for it.' },++ { name: 'bench.next_pair', title: 'Next blind pair', description: 'Fetch the next ready round this person has not judged, with the two entries shuffled and identities hidden.' },++ { name: 'bench.cast_judgement', title: 'Cast a judgement', description: 'Record a blind pairwise vote and reveal the two entries.' },++ { name: 'bench.get_board', title: 'Get a board', description: 'Render an elo, pareto or cost board for one kind.' },++ { name: 'bench.run_eval', title: 'Run a private eval', description: 'Create and run a private task suite against chosen offers.' },++]);++++const TOOLS = Object.freeze(TOOL_META.map((t) => ({ ...t, inputSchema: toolsInput()[t.name.slice(6)] })));++++function result(id, value) { return { jsonrpc: '2.0', id, result: value }; }++function error(id, code, message) { return { jsonrpc: '2.0', id: id === undefined ? null : id, error: { code, message } }; }++function validId(id) { return id === null || typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id)); }++++function subjectOf(req) {++ return req.principal && req.principal.kind === 'user' && req.principal.subject ? req.principal.subject : null;++}++++function requirePersonTool(id, req) {++ if (subjectOf(req)) return null;++ return { response: result(id, { content: [{ type: 'text', text: 'This tool needs a person: sign in, or send a Network user token as a Bearer.' }], isError: true }) };++}++++function rowText(obj) {++ return Object.entries(obj || {}).map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`).join(' · ');++}++++async function callTool(id, params, req, ctx) {++ const { bench } = ctx;++ const name = params.name;++ const tool = TOOLS.find((t) => t.name === name);++ if (!tool) return { response: error(id, -32602, 'Unknown tool') };++ const args = params.arguments && typeof params.arguments === 'object' && !Array.isArray(params.arguments) ? params.arguments : {};++++ switch (name) {++ case 'bench.list_kinds': {++ const kinds = await bench.listKinds();++ return { response: result(id, { content: [{ type: 'text', text: kinds.map((k) => `${k.id}: ${k.name} (${k.room}, ${k.judge})`).join('\n') }], structuredContent: { kinds }, isError: false }) };++ }++ case 'bench.start_round': {++ const denied = requirePersonTool(id, req);++ if (denied) return denied;++ const owner = subjectOf(req);++ const task = await bench.createTask({ kindId: args.kind, prompt: args.prompt, public: true, subject: owner });++ if (!task.ok) return { response: result(id, { content: [{ type: 'text', text: task.message }], isError: true }) };++ const out = await bench.createRound({ taskId: task.task.id, offers: args.offers, seed: args.seed, subject: owner });++ if (!out.ok) return { response: result(id, { content: [{ type: 'text', text: out.message }], isError: true }) };++ return { response: result(id, { content: [{ type: 'text', text: rowText(out.round) }], structuredContent: out.round, isError: false }) };++ }++ case 'bench.next_pair': {++ const denied = requirePersonTool(id, req);++ if (denied) return denied;++ const pair = await bench.nextJudgement({ kind: args.kind, subject: subjectOf(req) });++ if (!pair) return { response: result(id, { content: [{ type: 'text', text: 'No round waiting for your judgement.' }], isError: false }) };++ return { response: result(id, { content: [{ type: 'text', text: `Round ${pair.round.id}: choose left or right.` }], structuredContent: pair, isError: false }) };++ }++ case 'bench.cast_judgement': {++ const denied = requirePersonTool(id, req);++ if (denied) return denied;++ const out = await bench.castJudgement({++ roundId: args.round, kindId: args.kind, judge: 'human_pairwise',++ pair: { left: args.left, right: args.right }, outcome: args.outcome, subject: subjectOf(req),++ });++ return { response: result(id, { content: [{ type: 'text', text: rowText(out.judgement) }], structuredContent: out, isError: false }) };++ }++ case 'bench.get_board': {++ const board = await bench.board(args.kind, { type: args.type || 'elo', category: args.category || 'all', period: args.period || 'all' });++ if (!board) return { response: result(id, { content: [{ type: 'text', text: 'No such kind.' }], isError: true }) };++ const lines = board.rows.map((r) => rowText(r));++ return { response: result(id, { content: [{ type: 'text', text: lines.join('\n') || 'No rows yet.' }], structuredContent: { board }, isError: false }) };++ }++ case 'bench.run_eval': {++ const denied = requirePersonTool(id, req);++ if (denied) return denied;++ const owner = subjectOf(req);++ const ev = await bench.createEval({ owner, kindId: args.kind, name: `MCP eval ${args.kind}`, testSuite: args.test_suite });++ if (!ev.ok) return { response: result(id, { content: [{ type: 'text', text: ev.message }], isError: true }) };++ const run = await bench.runEval({ evalId: ev.eval.id, offers: args.offers, owner });++ return { response: result(id, { content: [{ type: 'text', text: `Eval ${ev.eval.id}, run ${run.id} (${run.status}).` }], structuredContent: { eval: ev.eval, run }, isError: false }) };++ }++ default:++ return { response: error(id, -32602, 'Unknown tool') };++ }++}++++async function handleMessage(msg, req, ctx) {++ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return { response: error(null, -32600, 'Invalid Request') };++ if (msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') return { response: error(null, -32600, 'Invalid Request') };++ const hasId = Object.prototype.hasOwnProperty.call(msg, 'id');++ if (hasId && !validId(msg.id)) return { response: error(null, -32600, 'Invalid Request') };++ if (!hasId || msg.method.startsWith('notifications/')) return { notification: true };++ const id = msg.id;++ const params = msg.params && typeof msg.params === 'object' && !Array.isArray(msg.params) ? msg.params : {};++ switch (msg.method) {++ case 'initialize': {++ const requested = params.protocolVersion;++ const protocolVersion = SUPPORTED_VERSIONS.has(requested) ? requested : PROTOCOL_VERSION;++ return { response: result(id, { protocolVersion, capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'openvibe-bench', title: 'OpenVibe Bench', version: pkg.version }, instructions: INSTRUCTIONS }) };++ }++ case 'ping':++ return { response: result(id, {}) };++ case 'tools/list':++ return { response: result(id, { tools: TOOLS }) };++ case 'tools/call':++ return await callTool(id, params, req, ctx);++ default:++ return { response: error(id, -32601, 'Method not found') };++ }++}++++function mcpRouter(ctx) {++ const router = express.Router();++ const json = express.json({ limit: BODY_LIMIT, type: ['application/json', 'application/*+json'], strict: false });++++ router.use('/mcp', (_req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); next(); });++ router.options('/mcp', (_req, res) => {++ res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');++ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Protocol-Version');++ res.setHeader('Access-Control-Max-Age', '600');++ res.status(204).end();++ });++++ router.post('/mcp', json, ctx.principal.middleware, async (req, res) => {++ const version = req.get('mcp-protocol-version');++ if (version && !SUPPORTED_VERSIONS.has(version)) {++ const body = req.body;++ const id = body && !Array.isArray(body) && validId(body.id) ? body.id : null;++ return res.status(400).json(error(id, -32600, 'Unsupported protocol version'));++ }++ const offered = req.body;++ if (!offered || typeof offered !== 'object') return res.status(200).json(error(null, -32600, 'Invalid Request'));++ const batch = Array.isArray(offered);++ if (batch && offered.length === 0) return res.status(200).json(error(null, -32600, 'Invalid Request'));++ const messages = batch ? offered : [offered];++ const responses = [];++ for (const message of messages) {++ const out = await handleMessage(message, req, ctx);++ if (out.response) responses.push(out.response);++ }++ if (!responses.length) return res.status(202).end();++ return res.status(200).json(batch ? responses : responses[0]);++ });++++ router.all('/mcp', (_req, res) => {++ res.setHeader('Allow', 'POST');++ res.status(405).json(error(null, -32600, 'Method Not Allowed'));++ });++++ // eslint-disable-next-line no-unused-vars++ router.use((err, req, res, next) => {++ if (err && err.type === 'entity.parse.failed') return res.status(400).json(error(null, -32700, 'Parse error'));++ if (err && err.type === 'entity.too.large') return res.status(413).json(error(null, -32600, 'Request body too large'));++ return next(err);++ });++++ return router;++}++++module.exports = { mcpRouter, TOOLS, INSTRUCTIONS, PROTOCOL_VERSION, SUPPORTED_VERSIONS };nnew file mode 100644@@@ -0,0 +1,37 @@++'use strict';++++/**++ * Truthful readiness for GET /api/ready (openvibe-shared/ready).++ */++const { jwksStatus } = require('openvibe-sdk/auth');++const { createReadiness } = require('openvibe-shared/ready');++++function createServiceReadiness({ s, config, valkey = null, release = null }) {++ return createReadiness({++ service: 'bench',++ release,++ checks: [++ {++ name: 'db', required: true,++ check: async () => {++ const r = await s.db.ready();++ return r.ok ? { ok: true, detail: r.detail } : r.error;++ },++ },++ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-caller limits count in this process only' }) },++ {++ name: 'network_jwks', required: false,++ check: () => {++ const statuses = jwksStatus();++ if (!statuses.length) return 'no JWKS clients (misconfigured)';++ const first = statuses[0];++ if (!first.ready) return 'Network signing key not loaded yet: sign-in and the API are unavailable';++ return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };++ },++ },++ { name: 'oauth_client', required: false, check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete') },++ ],++ });++}++++module.exports = { createServiceReadiness };nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++++/**++ * Bench's rating maths (plan 2026-10-28 §4.12). Simple, deterministic and correct enough to drop a full++ * Bradley–Terry library in behind the same interface later:++ *++ * eloUpdate(ra, rb, outcome, k) one Elo exchange, returns { a, b }++ * bradleyTerry(games, { bootstrap, seed }) refit ratings with bootstrap 95% intervals++ * wilson(successes, n) Wilson 95% lower/upper on a success rate++ * pareto(rows, { x, y }) non-dominated frontier over quality (x↑) vs cost (y↓)++ * costPerSolved(entries) total spend / solved count++ *++ * The bootstrap uses a small seedable LCG, so the same games and seed always give the same intervals.++ */++++const BASE_ELO = 1000;++const K_DEFAULT = 32;++++function eloUpdate(ra, rb, outcome, k = K_DEFAULT) {++ const a = Number(ra);++ const b = Number(rb);++ const K = Number(k) || K_DEFAULT;++ let sa;++ if (outcome === 'left' || outcome === 'win' || outcome === 1) sa = 1;++ else if (outcome === 'right' || outcome === 'loss' || outcome === 0) sa = 0;++ else if (outcome === 'tie' || outcome === 0.5) sa = 0.5;++ else if (outcome === 'both_bad') return { a, b };++ else return { a, b };++ const ea = 1 / (1 + 10 ** ((b - a) / 400));++ const eb = 1 - ea;++ return {++ a: Math.round((a + K * (sa - ea)) * 100) / 100,++ b: Math.round((b + K * ((1 - sa) - eb)) * 100) / 100,++ };++}++++/**++ * Non-dominated sort: maximize x (quality), minimize y (cost). A row is on the frontier when no other row has++ * x >= its x and y <= its y with at least one strict. Rows are returned with `frontier: true|false`.++ */++function pareto(rows, { x = 'x', y = 'y' } = {}) {++ const list = Array.isArray(rows) ? rows.map((r) => ({ ...r })) : [];++ const out = list.map((r) => ({ ...r, frontier: true }));++ for (let i = 0; i < out.length; i++) {++ for (let j = 0; j < out.length; j++) {++ if (i === j) continue;++ const a = out[i];++ const b = out[j];++ const betterX = Number(b[x] ?? -Infinity) >= Number(a[x] ?? -Infinity);++ const betterY = Number(b[y] ?? Infinity) <= Number(a[y] ?? Infinity);++ const strict = Number(b[x] ?? -Infinity) > Number(a[x] ?? -Infinity) || Number(b[y] ?? Infinity) < Number(a[y] ?? Infinity);++ if (betterX && betterY && strict) { a.frontier = false; break; }++ }++ }++ return out;++}++++/**++ * Expected cost per solved entry. `entries` are objects with a numeric `cost_usd` and a truthy `solved` flag++ * (or `outcome: 'pass'` / a positive `score`). Returns null when nothing is solved.++ */++function costPerSolved(entries) {++ const list = Array.isArray(entries) ? entries : [];++ let cost = 0;++ let solved = 0;++ for (const e of list) {++ const isSolved = Boolean(e && (e.solved || e.outcome === 'pass' || (Number(e.score) || 0) > 0));++ if (!isSolved) continue;++ cost += Number(e.cost_usd) || 0;++ solved += 1;++ }++ return solved ? Math.round((cost / solved) * 1e10) / 1e10 : null;++}++++// Bradley–Terry with bootstrap intervals, voter weights, Wilson and percentiles: the tested library (lib.js, BENCH-2).++const lib = require('./lib');++++module.exports = { eloUpdate, bradleyTerry: lib.bradleyTerry, voterWeights: lib.voterWeights, wilson: lib.wilson, percentile: lib.percentile, pareto, costPerSolved, BASE_ELO, K_DEFAULT };nnew file mode 100644@@@ -0,0 +1,174 @@++'use strict';++++const ELO_LOG_SCALE = 400 / Math.log(10);++const PRIOR = 0.1; // Half a win and half a loss against a unit-strength opponent.++++function eloUpdate(ra, rb, outcome, k = 32) {++ if (outcome === 'both_bad') return [ra, rb];++ if (!['a', 'b', 'tie'].includes(outcome)) throw new RangeError('invalid outcome');++ const score = outcome === 'a' ? 1 : outcome === 'b' ? 0 : 0.5;++ const expected = 1 / (1 + 10 ** ((rb - ra) / 400));++ const change = k * (score - expected);++ return [ra + change, rb - change];++}++++function percentile(values, p) {++ if (!values.length) return null;++ if (!Number.isFinite(p) || p < 0 || p > 100) throw new RangeError('percentile must be in [0, 100]');++ // Fractions are accepted as well: .5 is the median, .95 is p95.++ const fraction = p <= 1 ? p : p / 100;++ const sorted = [...values].sort((a, b) => a - b);++ const position = (sorted.length - 1) * fraction;++ const lower = Math.floor(position);++ const upper = Math.ceil(position);++ return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);++}++++function gameWeight(game, weights) {++ if (weights == null || game.voter == null) return 1;++ const value = typeof weights === 'function' ? weights(game.voter, game)++ : weights instanceof Map ? weights.get(game.voter) : weights[game.voter];++ if (value == null) return 1;++ if (!Number.isFinite(value) || value < 0) throw new RangeError('weights must be finite and nonnegative');++ return value;++}++++function fitStrengths(games, subjects, weights, iterations) {++ const index = new Map(subjects.map((subject, i) => [subject, i]));++ const wins = Array(subjects.length).fill(PRIOR / 2);++ const pairs = new Map();++ for (const game of games) {++ if (game.outcome === 'both_bad' || game.a === game.b) continue;++ const weight = gameWeight(game, weights);++ if (weight === 0) continue;++ const a = index.get(game.a);++ const b = index.get(game.b);++ const key = a < b ? `${a}:${b}` : `${b}:${a}`;++ let pair = pairs.get(key);++ if (!pair) {++ pair = { a: Math.min(a, b), b: Math.max(a, b), n: 0 };++ pairs.set(key, pair);++ }++ pair.n += weight;++ if (game.outcome === 'a') wins[a] += weight;++ else if (game.outcome === 'b') wins[b] += weight;++ else if (game.outcome === 'tie') {++ wins[a] += weight / 2;++ wins[b] += weight / 2;++ } else throw new RangeError('invalid outcome');++ }++++ let strength = Array(subjects.length).fill(1);++ for (let iteration = 0; iteration < iterations; iteration++) {++ const denominator = strength.map(value => PRIOR / (value + 1));++ for (const pair of pairs.values()) {++ const term = pair.n / (strength[pair.a] + strength[pair.b]);++ denominator[pair.a] += term;++ denominator[pair.b] += term;++ }++ const next = strength.map((_, i) => wins[i] / denominator[i]);++ const change = Math.max(0, ...next.map((value, i) => Math.abs(Math.log(value / strength[i]))));++ strength = next;++ if (change < 1e-10) break;++ }++ return strength;++}++++function ratingsFor(games, subjects, weights, iterations, anchor) {++ const strength = fitStrengths(games, subjects, weights, iterations);++ const logs = strength.map(Math.log);++ const mean = logs.reduce((sum, value) => sum + value, 0) / logs.length;++ return logs.map(value => anchor + ELO_LOG_SCALE * (value - mean));++}++++function seededRandom(seed) {++ let state = seed >>> 0;++ return () => {++ state = (state + 0x6D2B79F5) >>> 0;++ let value = state;++ value = Math.imul(value ^ (value >>> 15), value | 1);++ value ^= value + Math.imul(value ^ (value >>> 7), value | 61);++ return ((value ^ (value >>> 14)) >>> 0) / 4294967296;++ };++}++++function bradleyTerry(games, { bootstrap = 200, seed = 1, anchor = 1000, iterations = 200, weights } = {}) {++ const active = games.filter(game => game.outcome !== 'both_bad');++ const subjects = [...new Set(active.flatMap(game => [game.a, game.b]))].sort();++ if (!subjects.length) return [];++ const stats = new Map(subjects.map(subject => [subject, { games: 0, wins: 0, losses: 0, ties: 0 }]));++ for (const game of active) {++ if (!['a', 'b', 'tie'].includes(game.outcome)) throw new RangeError('invalid outcome');++ if (game.a === game.b) throw new RangeError('a game requires two different subjects');++ const a = stats.get(game.a);++ const b = stats.get(game.b);++ a.games++;++ b.games++;++ if (game.outcome === 'tie') { a.ties++; b.ties++; }++ else if (game.outcome === 'a') { a.wins++; b.losses++; }++ else { b.wins++; a.losses++; }++ }++ const ratings = ratingsFor(active, subjects, weights, iterations, anchor);++ const samples = subjects.map(() => []);++ if (bootstrap > 0 && active.length) {++ const random = seededRandom(seed);++ for (let i = 0; i < bootstrap; i++) {++ const resampled = Array.from({ length: active.length }, () => active[Math.floor(random() * active.length)]);++ const draw = ratingsFor(resampled, subjects, weights, iterations, anchor);++ draw.forEach((rating, j) => samples[j].push(rating));++ }++ }++ return subjects.map((subject, i) => ({++ subject,++ rating: ratings[i],++ ci_lower: samples[i].length ? percentile(samples[i], 2.5) : ratings[i],++ ci_upper: samples[i].length ? percentile(samples[i], 97.5) : ratings[i],++ ...stats.get(subject),++ provisional: stats.get(subject).games < 10,++ })).sort((a, b) => b.rating - a.rating || String(a.subject).localeCompare(String(b.subject)));++}++++function voterWeights(games, fit, { burnIn = 20, floor = 0.25 } = {}) {++ const ratings = new Map(fit.map(row => [row.subject, row.rating]));++ const voters = new Map();++ for (const game of games) {++ if (game.voter == null) continue;++ if (!voters.has(game.voter)) voters.set(game.voter, { count: 0, disagreement: 0 });++ if (game.outcome === 'both_bad' || !ratings.has(game.a) || !ratings.has(game.b)) continue;++ const record = voters.get(game.voter);++ const expected = Math.sign(ratings.get(game.a) - ratings.get(game.b));++ const observed = game.outcome === 'a' ? 1 : game.outcome === 'b' ? -1 : 0;++ record.count++;++ record.disagreement += expected === observed ? 0 : expected === 0 || observed === 0 ? 0.5 : 1;++ }++ return new Map([...voters].map(([voter, record]) => [voter,++ record.count < burnIn ? 1 : Math.max(floor, 1 - record.disagreement / record.count)]));++}++++function wilson(successes, n, z = 1.96) {++ if (n === 0) return { p: 0, lower: 0, upper: 0 };++ const p = successes / n;++ const z2 = z * z;++ const denominator = 1 + z2 / n;++ const center = (p + z2 / (2 * n)) / denominator;++ const radius = z * Math.sqrt((p * (1 - p) + z2 / (4 * n)) / n) / denominator;++ return { p, lower: center - radius, upper: center + radius };++}++++function pareto(rows, { x, y, lowerX = true, higherY = true }) {++ return rows.map((row, i) => ({ ...row, frontier: !rows.some((other, j) => {++ if (i === j) return false;++ const xBetter = lowerX ? other[x] <= row[x] : other[x] >= row[x];++ const yBetter = higherY ? other[y] >= row[y] : other[y] <= row[y];++ const strictlyBetter = other[x] !== row[x] || other[y] !== row[y];++ return xBetter && yBetter && strictlyBetter;++ }) }));++}++++function costPerSolved(entries) {++ const cost_usd = entries.reduce((sum, entry) => sum + entry.cost_usd, 0);++ const solved = entries.reduce((sum, entry) => sum + Number(entry.solved), 0);++ return { cost_usd, solved, per_solved: solved ? cost_usd / solved : null };++}++++module.exports = { eloUpdate, bradleyTerry, voterWeights, wilson, pareto, costPerSolved, percentile };nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++++/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }++const raw = (s) => new Raw(s);++function html(strings, ...values) {++ let out = strings[0];++ values.forEach((v, i) => { out += render(v) + strings[i + 1]; });++ return raw(out);++}++function render(v) {++ if (v == null || v === false) return '';++ if (v instanceof Raw) return v.s;++ if (Array.isArray(v)) return v.map(render).join('');++ return esc(v);++}++++const code = (s) => html`<code>${s}</code>`;++const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');++++function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {++ if (!rows.length) return html`<p class="muted">${empty}</p>`;++ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;++}++++const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;++const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;++++module.exports = { esc, html, raw, Raw, render, code, time, table, notice, badge };nnew file mode 100644@@@ -0,0 +1,105 @@++'use strict';++++/**++ * Page shell. Every page is server-rendered through openvibe-shared/shell and complete without JavaScript.++ */++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const appIcon = require('openvibe-shared/app-icon');++const shell = require('openvibe-shared/shell');++const rooms = require('openvibe-shared/rooms');++const ovServe = require('openvibe-shared/serve');++const cache = require('openvibe-shared/cache-policy');++const { render, esc } = require('./html');++++const NETWORK_URL = 'https://openvibe.network';++const SITE_NAME = 'OpenVibe.Bench';++const TAGLINE = 'One AI benchmark and comparison engine for every room.';++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');++const NAV = [++ { label: 'Home', href: '/' },++ { label: 'Docs', href: '/docs' },++];++++const hashes = new Map();++function assetVersion(rel) {++ if (hashes.has(rel)) return hashes.get(rel);++ let v = 'dev';++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }++ hashes.set(rel, v);++ return v;++}++const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;++++let RELEASE = 'dev';++function setRelease(id) { if (id) RELEASE = String(id); }++++function renderPage(o) {++ const viewer = o.viewer || { kind: 'anonymous' };++ const signedIn = viewer.kind === 'user';++ const pathNow = o.path || '/';++ const loginNext = encodeURIComponent(pathNow);++ const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;++ const nav = {++ service: 'bench',++ apiBase: NETWORK_URL,++ links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),++ history: { type: 'page', title: o.title || SITE_NAME },++ silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,++ sessionUrl: '/auth/me',++ loginUrl: '/auth/login?next={path}',++ logoutUrl: '/auth/logout?next={path}',++ notificationsRealtime: true,++ };++ const footer = { service: 'bench', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };++ const account = signedIn++ ? `Signed in as <strong>${esc(viewer.displayName || viewer.username || 'you')}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`++ : `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;++ const scripts = (o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`);++ return shell.page({++ name: SITE_NAME,++ lang: 'en',++ title: o.title || `${SITE_NAME}: ${TAGLINE}`,++ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',++ siteName: SITE_NAME,++ description: o.description || `${SITE_NAME} — ${TAGLINE}`,++ canonical,++ robots: o.index ? 'index, follow' : 'noindex, nofollow',++ jsonLd: (o.jsonLd || []).filter(Boolean),++ home: '/',++ navLinks: NAV,++ navbar: nav,++ footer,++ head: `<meta name="referrer" content="strict-origin-when-cross-origin">++${appIcon.headTags({ site: 'bench' })}++<link rel="stylesheet" href="${ovServe.url('kit.css')}">++<link rel="stylesheet" href="${ovServe.url('room.css')}">++<link rel="stylesheet" href="${asset('css/bench.css')}">++<script src="${asset('js/bench.js')}" defer></script>++${render(scripts)}++<meta name="ov-boost" content="bench@${esc(RELEASE)}">++<script src="${require('openvibe-shared/serve').url('boost.js')}" data-main="#main" defer></script>`,++ body: `<a class="skip" href="#main">Skip to content</a>++<div id="navbar-mount"></div>++<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>++<main id="main" class="bm ov-room" style="${rooms.style('bench')}">++${render(o.body)}++</main>++<script>++window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};++document.addEventListener('DOMContentLoaded', function () {++ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }++});++</script>`,++ });++}++++function send(res, status, o) {++ const personal = o.viewer && o.viewer.kind === 'user';++ if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));++ res.set('Vary', 'Cookie');++ res.status(status).type('html').send(renderPage(o));++}++++module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, TAGLINE, NETWORK_URL, NAV };nnew file mode 100644@@@ -0,0 +1,640 @@++'use strict';++++/**++ * Bench's data access: kinds, tasks, rounds, entries, judgements, ratings, boards, the open dataset and private++ * evals. A judgement and the rating changes (plus the outbox rows) commit in one transaction, so a crash can++ * never leave a vote without its Elo update. One vote per person per round; a creator cannot vote on their own++ * round; identities stay hidden until a caller has voted (or started the round).++ */++const crypto = require('crypto');++const { ids } = require('openvibe-contracts');++const { KINDS, JUDGES, WINNERS, kindById } = require('./kinds');++const ratingLib = require('./rating');++++const BASE_ELO = ratingLib.BASE_ELO;++const K = 32;++const CATEGORY_DEFAULT = 'all';++++const num = (v, d = 0) => (v == null || v === '' || Number.isNaN(Number(v)) ? d : Number(v));++const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));++const refusal = (status, code, message) => ({ ok: false, status, code, message });++const sha256 = (s) => crypto.createHash('sha256').update(String(s), 'utf8').digest('hex');++++class BenchError extends Error {++ constructor(status, code, detail) {++ super(detail);++ this.name = 'BenchError';++ this.status = status;++ this.code = code;++ this.detail = detail;++ }++}++++function wireKind(row) {++ if (!row) return null;++ let schema = row.schema;++ let metrics = row.metrics;++ if (typeof schema === 'string') { try { schema = JSON.parse(schema); } catch { schema = {}; } }++ if (typeof metrics === 'string') { try { metrics = JSON.parse(metrics); } catch { metrics = []; } }++ return { id: row.id, room: row.room, entry: row.entry, judge: row.judge, name: row.name, schema, metrics: Array.isArray(metrics) ? metrics : [], created_at: iso(row.created_at) };++}++++function wireTask(row) {++ if (!row) return null;++ return { id: row.id, kind_id: row.kind_id, prompt: row.prompt, task_class: row.task_class == null ? null : row.task_class, public: Boolean(row.public), created_by: row.created_by, created_at: iso(row.created_at) };++}++++function wireRound(row) {++ if (!row) return null;++ let offers = row.offers;++ let params = row.params;++ if (typeof offers === 'string') { try { offers = JSON.parse(offers); } catch { offers = []; } }++ if (typeof params === 'string') { try { params = JSON.parse(params); } catch { params = {}; } }++ return {++ id: row.id, task_id: row.task_id, kind_id: row.kind_id,++ offers: Array.isArray(offers) ? offers : [],++ status: row.status, seed: num(row.seed), params,++ pool_fallback: Boolean(row.pool_fallback),++ receipt: row.receipt == null ? null : row.receipt,++ created_by: row.created_by, created_at: iso(row.created_at), finished_at: iso(row.finished_at),++ };++}++++function wireEntry(row, { blind = false } = {}) {++ if (!row) return null;++ let payload = row.payload;++ let route = row.route;++ let uiCheck = row.ui_check;++ if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch { payload = {}; } }++ if (typeof route === 'string') { try { route = JSON.parse(route); } catch { route = {}; } }++ if (typeof uiCheck === 'string') { try { uiCheck = JSON.parse(uiCheck); } catch { uiCheck = null; } }++ const base = {++ id: row.id,++ round_id: row.round_id,++ payload,++ error: row.error || null,++ ui_check: uiCheck == null ? null : uiCheck,++ run_id: row.run_id || null,++ created_at: iso(row.created_at),++ };++ if (blind) return base;++ return { ...base, provider: row.provider, model: row.model, route, cost_usd: num(row.cost_usd), ms: num(row.ms), tokens: num(row.tokens) };++}++++function wireJudgement(row, { withSubject = false } = {}) {++ if (!row) return null;++ let pair = row.pair;++ if (typeof pair === 'string') { try { pair = JSON.parse(pair); } catch { pair = null; } }++ const out = {++ id: row.id, round_id: row.round_id, kind_id: row.kind_id,++ judge: row.judge, judge_name: row.judge_name || null, judge_version: row.judge_version || null,++ method: row.method, pair, outcome: row.outcome || null, score: row.score == null ? null : num(row.score), at: iso(row.at),++ };++ if (withSubject) out.subject = row.subject || null;++ return out;++}++++function wireRating(row) {++ if (!row) return null;++ return {++ kind_id: row.kind_id, category: row.category, subject: row.subject,++ elo: num(row.elo, BASE_ELO), games: num(row.games), wins: num(row.wins), losses: num(row.losses), ties: num(row.ties),++ win_rate: num(row.win_rate), ci_lower: row.ci_lower == null ? null : num(row.ci_lower),++ ci_upper: row.ci_upper == null ? null : num(row.ci_upper),++ median_cost_usd: row.median_cost_usd == null ? null : num(row.median_cost_usd),++ median_ms: row.median_ms == null ? null : num(row.median_ms),++ };++}++++function wireEval(row) {++ if (!row) return null;++ let suite = row.test_suite;++ if (typeof suite === 'string') { try { suite = JSON.parse(suite); } catch { suite = []; } }++ return { id: row.id, owner: row.owner, kind_id: row.kind_id, name: row.name, test_suite: Array.isArray(suite) ? suite : [], created_at: iso(row.created_at) };++}++++function wireEvalRun(row) {++ if (!row) return null;++ let offers = row.offers;++ let results = row.results;++ if (typeof offers === 'string') { try { offers = JSON.parse(offers); } catch { offers = []; } }++ if (typeof results === 'string') { try { results = JSON.parse(results); } catch { results = null; } }++ return { id: row.id, eval_id: row.eval_id, offers: Array.isArray(offers) ? offers : [], status: row.status, results, created_at: iso(row.created_at), finished_at: iso(row.finished_at) };++}++++function shuffle(list, random = Math.random) {++ const a = list.slice();++ for (let i = a.length - 1; i > 0; i--) {++ const j = Math.floor(random() * (i + 1));++ [a[i], a[j]] = [a[j], a[i]];++ }++ return a;++}++++function eloDelta(p) {++ if (!(p > 0)) return -400;++ if (p >= 1) return 400;++ return 400 * Math.log10(p / (1 - p));++}++++/** Wilson bounds on win rate, mapped to an Elo band around the live rating. */++function ratingCi(elo, wins, losses, ties) {++ const games = wins + losses + ties;++ if (!games) return { ci_lower: null, ci_upper: null };++ const p = (wins + ties * 0.5) / games;++ const w = ratingLib.wilson(wins + ties * 0.5, games);++ const delta = eloDelta(p);++ return {++ ci_lower: Math.round((elo + (eloDelta(w.lower) - delta)) * 100) / 100,++ ci_upper: Math.round((elo + (eloDelta(w.upper) - delta)) * 100) / 100,++ };++}++++function createStore(s, { log = console, random = Math.random, config = null } = {}) {++ const db = s.db;++++ // ── Outbox (in the same ambient transaction as the change that caused it) ──++ async function insertOutbox(topic, payload) {++ const eventId = ids.newId('event', s.now());++ await db.exec(++ `INSERT INTO bench_outbox (event_id, topic, payload, created_at, next_attempt_at)++ VALUES ($1, $2, $3::jsonb, $4::timestamptz, $4::timestamptz)`,++ [eventId, topic, JSON.stringify(payload), s.iso()],++ );++ return eventId;++ }++++ // ── Kinds ───────────────────────────────────────────────++ async function seedKinds() {++ for (const k of KINDS) {++ await db.exec(++ `INSERT INTO bench_kinds (id, room, entry, judge, name, schema, metrics, created_at)++ VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, now())++ ON CONFLICT (id) DO UPDATE SET room = EXCLUDED.room, entry = EXCLUDED.entry, judge = EXCLUDED.judge,++ name = EXCLUDED.name, schema = EXCLUDED.schema, metrics = EXCLUDED.metrics`,++ [k.id, k.room, k.entry, k.judge, k.name, JSON.stringify(k.schema), JSON.stringify(k.metrics)],++ );++ }++ }++ async function listKinds() {++ return (await db.many('SELECT * FROM bench_kinds ORDER BY id')).map(wireKind);++ }++ async function getKind(id) {++ return wireKind(await db.maybe('SELECT * FROM bench_kinds WHERE id = $1', [String(id || '')]));++ }++++ // ── Tasks ───────────────────────────────────────────────++ async function createTask({ kindId, prompt, taskClass = null, public: isPublic = false, subject }) {++ const kind = await getKind(kindId);++ if (!kind) return refusal(422, 'bench.kind_not_found', `No such kind: ${kindId}`);++ const text = String(prompt == null ? '' : prompt).replace(/\s+/g, ' ').trim();++ if (!text) return refusal(422, 'bench.prompt_required', 'A task needs a prompt or spec.');++ if (text.length > 4000) return refusal(422, 'bench.prompt_too_long', 'The prompt must be at most 4000 characters.');++ const id = s.newId('btk');++ const row = await db.maybe(++ `INSERT INTO bench_tasks (id, kind_id, prompt, task_class, public, created_by, created_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7::timestamptz) RETURNING *`,++ [id, kind.id, text, taskClass, Boolean(isPublic), subject, s.iso()],++ );++ return { ok: true, task: wireTask(row) };++ }++ async function getTask(id) {++ return wireTask(await db.maybe('SELECT * FROM bench_tasks WHERE id = $1', [String(id || '')]));++ }++++ // ── Rounds ──────────────────────────────────────────────++ const dayStart = (at = s.now()) => {++ const d = new Date(at);++ d.setUTCHours(0, 0, 0, 0);++ return d.toISOString();++ };++++ async function roundCounts(subject) {++ const start = dayStart();++ const perDay = config ? config.bench.roundsPerDay : 3;++ const daily = config ? config.bench.roundsDaily : 20;++ const used = num(await db.value('SELECT COUNT(*)::int AS n FROM bench_rounds WHERE created_by = $1 AND created_at >= $2::timestamptz', [subject, start]));++ const siteUsed = num(await db.value('SELECT COUNT(*)::int AS n FROM bench_rounds WHERE created_at >= $1::timestamptz', [start]));++ return {++ used,++ remaining: Math.max(0, perDay - used),++ per_day: perDay,++ site_used: siteUsed,++ site_daily: daily,++ site_remaining: Math.max(0, daily - siteUsed),++ };++ }++++ async function createRound({ taskId, offers = null, seed = 0, subject }) {++ const task = await getTask(taskId);++ if (!task) return refusal(404, 'bench.task_not_found', 'No such task.');++ const kind = await getKind(task.kind_id);++ if (!kind) return refusal(422, 'bench.kind_not_found', 'The task\'s kind is gone.');++ const off = Array.isArray(offers) ? offers.slice(0, 3).map((o) => ({ provider: String(o.provider || ''), model: o.model == null ? null : String(o.model) })) : [];++ if (off.some((o) => !o.provider)) return refusal(422, 'bench.invalid_offers', 'Every offer needs a provider.');++ const perDay = config ? config.bench.roundsPerDay : 3;++ const daily = config ? config.bench.roundsDaily : 20;++ const start = dayStart();++ const id = s.newId('brd');++ const row = await db.maybe(++ `INSERT INTO bench_rounds (id, task_id, kind_id, offers, status, seed, params, pool_fallback, created_by, created_at)++ SELECT $1, $2, $3, $4::jsonb, 'queued', $5, '{}'::jsonb, $6, $7, $8::timestamptz++ WHERE (SELECT COUNT(*) FROM bench_rounds WHERE created_by = $7 AND created_at >= $9::timestamptz) < $10++ AND (SELECT COUNT(*) FROM bench_rounds WHERE created_at >= $9::timestamptz) < $11++ RETURNING *`,++ [id, task.id, kind.id, JSON.stringify(off), Math.max(0, Number(seed) || 0), off.length < 3, subject, s.iso(), start, perDay, daily],++ );++ if (!row) {++ const c = await roundCounts(subject);++ if (c.used >= perDay) throw new BenchError(429, 'bench.limit_person', `You can start ${perDay} round${perDay === 1 ? '' : 's'} a day.`);++ throw new BenchError(429, 'bench.limit_site', `Bench has reached its ${daily} round${daily === 1 ? '' : 's'} for today.`);++ }++ return { ok: true, round: await getRound(id) };++ }++++ async function getRound(id, { viewerSubject = null } = {}) {++ const r = await db.maybe('SELECT * FROM bench_rounds WHERE id = $1', [String(id || '')]);++ if (!r) return null;++ const entryRows = await db.many('SELECT * FROM bench_entries WHERE round_id = $1 ORDER BY created_at, id', [r.id]);++ const revealed = viewerSubject != null && (r.created_by === viewerSubject++ || await db.value('SELECT COUNT(*)::int AS n FROM bench_judgements WHERE round_id = $1 AND subject = $2', [r.id, viewerSubject]) > 0);++ const votes = num(await db.value('SELECT COUNT(*)::int AS n FROM bench_judgements WHERE round_id = $1', [r.id]));++ const entries = entryRows.map((e) => wireEntry(e, { blind: !revealed }));++ const out = { ...wireRound(r), entries, votes };++ if (!revealed) out.blind = true;++ return out;++ }++++ /** Insert an entry into a round; moves a room-submitted round to ready when it has two usable entries. */++ async function addEntry({ roundId, provider, model, route, payload, costUsd = 0, ms = 0, tokens = 0, error = null, uiCheck = null, runId = null }) {++ return await s.tx(async () => {++ const r = await db.maybe('SELECT * FROM bench_rounds WHERE id = $1 FOR UPDATE', [String(roundId || '')]);++ if (!r) throw new BenchError(404, 'bench.round_not_found', 'No such Bench round.');++ const id = s.newId('bte');++ const row = await db.maybe(++ `INSERT INTO bench_entries (id, round_id, provider, model, route, payload, cost_usd, ms, tokens, error, ui_check, run_id, created_at)++ VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11::jsonb, $12, $13::timestamptz) RETURNING *`,++ [id, r.id, String(provider || 'ai').slice(0, 80), String(model || 'unknown').slice(0, 120), JSON.stringify(route || {}),++ JSON.stringify(payload || {}), num(costUsd), Math.max(0, num(ms)), Math.max(0, num(tokens)), error || null,++ uiCheck == null ? null : JSON.stringify(uiCheck), runId || null, s.iso()],++ );++ await maybeReady(r.id);++ return wireEntry(row);++ });++ }++++ async function maybeReady(roundId) {++ const usable = num(await db.value('SELECT COUNT(*)::int AS n FROM bench_entries WHERE round_id = $1 AND error IS NULL', [roundId]));++ if (usable >= 2) {++ const r = await db.maybe('SELECT * FROM bench_rounds WHERE id = $1 FOR UPDATE', [roundId]);++ if (r && (r.status === 'queued' || r.status === 'running')) {++ await finishRound(r.id);++ }++ }++ }++++ async function finishRound(roundId) {++ const r = await db.maybe('SELECT * FROM bench_rounds WHERE id = $1', [roundId]);++ if (!r) return;++ const receipt = sha256(JSON.stringify({ id: r.id, task_id: r.task_id, kind_id: r.kind_id, offers: r.offers, seed: r.seed, params: r.params }));++ await db.exec("UPDATE bench_rounds SET status = 'finished', receipt = $1, finished_at = $2::timestamptz WHERE id = $3",++ [receipt, s.iso(), r.id]);++ await insertOutbox('bench.round.finished', {++ round_id: r.id, kind_id: r.kind_id, task_id: r.task_id, receipt, created_by: r.created_by,++ });++ }++++ // ── Judgements ──────────────────────────────────────────++ async function pairwiseGames(kindId, category = CATEGORY_DEFAULT) {++ const rows = await db.many(++ `SELECT * FROM bench_judgements++ WHERE kind_id = $1 AND method IN ('human_pairwise', 'game_outcome') AND pair IS NOT NULL++ AND ($2::text = 'all' OR outcome IS NOT NULL)++ ORDER BY at, id`,++ [kindId, category],++ );++ const out = [];++ for (const j of rows) {++ const pair = typeof j.pair === 'string' ? JSON.parse(j.pair) : j.pair;++ if (!pair || !pair.left || !pair.right) continue;++ if (j.outcome === 'both_bad') continue;++ out.push({ a: pair.left, b: pair.right, outcome: j.outcome === 'left' ? 1 : j.outcome === 'right' ? 0 : 0.5 });++ }++ return out;++ }++++ /** A ready round this person has not judged and did not start, with two entries shuffled and identities hidden. */++ async function nextJudgement({ kind, subject, random: rng = random } = {}) {++ const row = await db.maybe(++ `SELECT r.* FROM bench_rounds r++ WHERE r.kind_id = $1 AND r.status = 'finished' AND r.created_by <> $2++ AND (SELECT COUNT(*) FROM bench_entries e WHERE e.round_id = r.id AND e.error IS NULL) >= 2++ AND NOT EXISTS (SELECT 1 FROM bench_judgements j WHERE j.round_id = r.id AND j.subject = $2)++ ORDER BY r.created_at, r.id LIMIT 1`,++ [String(kind || ''), subject],++ );++ if (!row) return null;++ const entries = (await db.many('SELECT * FROM bench_entries WHERE round_id = $1 AND error IS NULL ORDER BY id', [row.id])).map(wireEntry);++ if (entries.length < 2) return null;++ const [left, right] = shuffle(entries, rng).slice(0, 2);++ return {++ round: { id: row.id, kind_id: row.kind_id, prompt: (await getTask(row.task_id)).prompt },++ left: { id: left.id, payload: left.payload },++ right: { id: right.id, payload: right.payload },++ };++ }++++ async function castJudgement({ roundId, kindId, judge, judgeName = null, judgeVersion = null, pair = null, outcome = null, score = null, subject }) {++ if (!JUDGES.includes(judge)) throw new BenchError(422, 'bench.invalid_judge', 'Unknown judge.');++ try {++ return await s.tx(async () => {++ const r = await db.maybe('SELECT * FROM bench_rounds WHERE id = $1 FOR UPDATE', [String(roundId || '')]);++ if (!r) throw new BenchError(404, 'bench.round_not_found', 'No such Bench round.');++ if (r.status !== 'finished' && r.status !== 'ready') throw new BenchError(409, 'bench.round_not_ready', 'This round is not ready to judge yet.');++++ let left = null;++ let right = null;++ if (judge === 'human_pairwise' || judge === 'game_outcome') {++ if (r.created_by === subject) throw new BenchError(403, 'bench.creator_cannot_vote', 'You cannot judge a round you started.');++ const p = pair && typeof pair === 'object' ? pair : {};++ if (!p.left || !p.right || p.left === p.right) throw new BenchError(422, 'bench.invalid_pair', 'A pairwise judgement needs two different entries.');++ const prior = await db.maybe('SELECT 1 AS n FROM bench_judgements WHERE round_id = $1 AND subject = $2', [r.id, subject]);++ if (prior) throw new BenchError(409, 'bench.already_judged', 'You have already judged this round.');++ const rows = await db.many('SELECT * FROM bench_entries WHERE round_id = $1 AND id = ANY($2::text[]) AND error IS NULL', [r.id, [p.left, p.right]]);++ if (rows.length !== 2) throw new BenchError(422, 'bench.invalid_pair', 'Those two entries are not both part of this round.');++ left = wireEntry(rows.find((o) => o.id === p.left));++ right = wireEntry(rows.find((o) => o.id === p.right));++ const allowed = judge === 'game_outcome' ? ['left', 'right', 'draw'] : WINNERS;++ if (!allowed.includes(outcome)) throw new BenchError(422, 'bench.invalid_outcome', `The outcome must be ${allowed.join(', ')}.`);++ }++++ const id = s.newId('bju');++ const row = await db.maybe(++ `INSERT INTO bench_judgements (id, round_id, kind_id, judge, judge_name, judge_version, method, pair, outcome, score, subject, at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12::timestamptz) RETURNING *`,++ [id, r.id, r.kind_id, judge, judgeName, judgeVersion, judge, pair ? JSON.stringify(pair) : null,++ outcome || null, score == null ? null : num(score), subject || null, s.iso()],++ );++ if (judge === 'human_pairwise' || judge === 'game_outcome') {++ await applyPairwiseRating(r.id, r.kind_id, left, right, outcome);++ }++ await insertOutbox('bench.judgement.recorded', {++ judgement_id: id, round_id: r.id, kind_id: r.kind_id, judge, method: judge, outcome: outcome || null, score: score == null ? null : num(score),++ });++ return { judgement: wireJudgement(row), revealed: left && right ? { left, right } : null };++ });++ } catch (err) {++ if (err && err.code === '23505') throw new BenchError(409, 'bench.already_judged', 'You have already judged this round.');++ throw err;++ }++ }++++ async function applyPairwiseRating(roundId, kindId, left, right, outcome) {++ if (outcome === 'both_bad') return;++ if (!left || !right || left.model === right.model) return;++ const models = [left.model, right.model];++ const now = s.iso();++ for (const model of models) {++ await db.exec(++ `INSERT INTO bench_ratings (kind_id, category, subject, elo, games, wins, losses, ties, win_rate, ci_lower, ci_upper, updated_at)++ VALUES ($1, $2, $3, $4, 0, 0, 0, 0, 0, NULL, NULL, $5::timestamptz)++ ON CONFLICT (kind_id, category, subject) DO NOTHING`,++ [kindId, CATEGORY_DEFAULT, model, BASE_ELO, now],++ );++ }++ const rows = await db.many('SELECT * FROM bench_ratings WHERE kind_id = $1 AND category = $2 AND subject = ANY($3::text[]) ORDER BY subject FOR UPDATE',++ [kindId, CATEGORY_DEFAULT, models]);++ const byModel = new Map(rows.map((r) => [r.subject, r]));++ const a = byModel.get(left.model);++ const b = byModel.get(right.model);++ if (!a || !b) return;++ const eloOutcome = outcome === 'draw' ? 'tie' : outcome;++ const update = ratingLib.eloUpdate(num(a.elo, BASE_ELO), num(b.elo, BASE_ELO), eloOutcome, K);++ const sa = outcome === 'left' ? 1 : outcome === 'right' ? 0 : 0.5;++ const sb = 1 - sa;++ for (const [row, elo, score, opp] of [[a, update.a, sa, sb], [b, update.b, sb, sa]]) {++ const games = num(row.games) + 1;++ const wins = num(row.wins) + (score > opp ? 1 : 0);++ const losses = num(row.losses) + (score < opp ? 1 : 0);++ const ties = num(row.ties) + (score === opp ? 1 : 0);++ const ci = ratingCi(elo, wins, losses, ties);++ await db.exec(++ `UPDATE bench_ratings SET elo = $1, games = $2, wins = $3, losses = $4, ties = $5,++ win_rate = $6, ci_lower = $7, ci_upper = $8, updated_at = $9::timestamptz++ WHERE kind_id = $10 AND category = $11 AND subject = $12`,++ [elo, games, wins, losses, ties, games ? Math.round(((wins + ties * 0.5) / games) * 10000) / 10000 : 0,++ ci.ci_lower, ci.ci_upper, now, kindId, CATEGORY_DEFAULT, row.subject],++ );++ }++ await insertOutbox('bench.rating.changed', { kind_id: kindId, category: CATEGORY_DEFAULT, subjects: models });++ }++++ // ── Boards ──────────────────────────────────────────────++ async function subjectsFor(kindId) {++ const rows = await db.many(++ `SELECT DISTINCT e.model FROM bench_entries e JOIN bench_rounds r ON r.id = e.round_id++ WHERE r.kind_id = $1 AND e.error IS NULL ORDER BY e.model`,++ [kindId],++ );++ return rows.map((r) => r.model);++ }++++ async function boardElo(kindId) {++ const subjects = await subjectsFor(kindId);++ const ratings = await db.many('SELECT * FROM bench_ratings WHERE kind_id = $1 AND category = $2', [kindId, CATEGORY_DEFAULT]);++ const by = new Map(ratings.map((r) => [r.subject, wireRating(r)]));++ const rows = subjects.map((subject) => {++ const r = by.get(subject);++ const games = r ? r.games : 0;++ return {++ kind_id: kindId, category: CATEGORY_DEFAULT, subject,++ elo: r ? r.elo : BASE_ELO,++ games, wins: r ? r.wins : 0, losses: r ? r.losses : 0, ties: r ? r.ties : 0,++ win_rate: r ? r.win_rate : 0,++ ci_lower: r ? r.ci_lower : null,++ ci_upper: r ? r.ci_upper : null,++ provisional: games < 10,++ };++ });++ rows.sort((a, b) => b.elo - a.elo || a.subject.localeCompare(b.subject));++ return rows;++ }++++ async function costStats(kindId) {++ const rows = await db.many(++ `SELECT e.model,++ COUNT(*)::int AS entries,++ COALESCE(SUM(e.cost_usd), 0)::numeric AS total_cost,++ COUNT(*) FILTER (WHERE e.error IS NULL)::int AS usable,++ percentile_cont(0.5) WITHIN GROUP (ORDER BY e.cost_usd) AS median_cost,++ percentile_cont(0.5) WITHIN GROUP (ORDER BY e.ms) AS median_ms++ FROM bench_entries e JOIN bench_rounds r ON r.id = e.round_id++ WHERE r.kind_id = $1 AND e.error IS NULL++ GROUP BY e.model`,++ [kindId],++ );++ return new Map(rows.map((r) => [r.model, r]));++ }++++ async function passRate(kindId) {++ const rows = await db.many(++ `SELECT e.model, COUNT(*)::int AS trials, COUNT(*) FILTER (WHERE j.outcome = 'pass' OR COALESCE(j.score, 0) > 0)::int AS passed++ FROM bench_entries e++ JOIN bench_rounds r ON r.id = e.round_id++ JOIN bench_judgements j ON j.round_id = r.id++ WHERE r.kind_id = $1 AND e.error IS NULL++ GROUP BY e.model`,++ [kindId],++ );++ return new Map(rows.map((r) => [r.model, r]));++ }++++ async function board(kindId, { type = 'elo', category = CATEGORY_DEFAULT, period = 'all' } = {}) {++ const kind = await getKind(kindId);++ if (!kind) return null;++ const cost = await costStats(kindId);++ const pass = await passRate(kindId);++ const eloRows = await boardElo(kindId);++++ let rows;++ if (type === 'elo') {++ rows = eloRows.map((r) => ({ ...r, median_cost_usd: num(cost.get(r.subject)?.median_cost), median_ms: num(cost.get(r.subject)?.median_ms) }));++ } else if (type === 'pareto') {++ const subjects = await subjectsFor(kindId);++ rows = subjects.map((subject) => {++ const e = eloRows.find((x) => x.subject === subject) || {};++ const p = pass.get(subject);++ const quality = p ? (num(p.trials) ? num(p.passed) / num(p.trials) : 0) : (num(e.win_rate) || 0);++ const medianCost = num(cost.get(subject)?.median_cost);++ return { subject, quality, win_rate: num(e.win_rate) || 0, pass_rate: p ? (num(p.trials) ? num(p.passed) / num(p.trials) : 0) : 0, cost: medianCost, median_cost_usd: medianCost, median_ms: num(cost.get(subject)?.median_ms), provisional: num(e.games) < 10 };++ });++ rows = ratingLib.pareto(rows, { x: 'quality', y: 'cost' });++ } else if (type === 'cost') {++ rows = [...cost.values()].map((r) => {++ const solved = num(pass.get(r.model)?.passed) || num(r.usable) || 0;++ const totalCost = num(r.total_cost);++ return {++ subject: r.model, entries: num(r.entries), usable: num(r.usable),++ total_cost_usd: Math.round(totalCost * 1e6) / 1e6,++ solved,++ cost_per_solved: solved ? Math.round((totalCost / solved) * 1e6) / 1e6 : null,++ median_cost_usd: num(r.median_cost),++ median_ms: num(r.median_ms),++ };++ });++ rows.sort((a, b) => (a.cost_per_solved == null ? Infinity : a.cost_per_solved) - (b.cost_per_solved == null ? Infinity : b.cost_per_solved) || a.subject.localeCompare(b.subject));++ } else {++ return null;++ }++ return { kind_id: kindId, category, period, type, rows };++ }++++ async function modelSummary(model) {++ const name = String(model || '');++ const ratings = (await db.many('SELECT * FROM bench_ratings WHERE subject = $1 ORDER BY kind_id, category', [name])).map(wireRating);++ const entries = (await db.many(++ `SELECT e.*, r.kind_id FROM bench_entries e JOIN bench_rounds r ON r.id = e.round_id WHERE e.model = $1 ORDER BY e.created_at DESC LIMIT 100`,++ [name],++ )).map((e) => ({ ...wireEntry(e), kind_id: e.kind_id }));++ const cost = num(await db.value('SELECT COALESCE(SUM(cost_usd), 0)::numeric AS n FROM bench_entries WHERE model = $1', [name]));++ return { model: name, ratings, entries, total_cost_usd: Math.round(cost * 1e6) / 1e6 };++ }++++ // ── Dataset (open, no voter ids) ─────────────────────────++ async function dataset(kindId) {++ const kind = await getKind(kindId);++ if (!kind) return null;++ const tasks = (await db.many('SELECT * FROM bench_tasks WHERE kind_id = $1 AND public = true ORDER BY created_at, id', [kindId]))++ .map(wireTask).map(({ created_by, ...rest }) => rest);++ const rounds = (await db.many("SELECT * FROM bench_rounds WHERE kind_id = $1 AND status IN ('ready', 'finished') ORDER BY created_at, id", [kindId]))++ .map(wireRound).map(({ created_by, ...rest }) => rest);++ const roundIds = rounds.map((r) => r.id);++ let entries = [];++ let judgements = [];++ if (roundIds.length) {++ entries = (await db.many('SELECT * FROM bench_entries WHERE round_id = ANY($1::text[]) ORDER BY created_at, id', [roundIds])).map(wireEntry);++ judgements = (await db.many('SELECT * FROM bench_judgements WHERE round_id = ANY($1::text[]) ORDER BY at, id', [roundIds])).map((j) => wireJudgement(j));++ }++ const ratings = (await db.many('SELECT * FROM bench_ratings WHERE kind_id = $1 ORDER BY subject', [kindId])).map(wireRating);++ return { kind_id: kindId, tasks, rounds, entries, judgements, ratings };++ }++++ async function datasetJsonl(kindId) {++ const d = await dataset(kindId);++ if (!d) return null;++ const lines = [];++ for (const t of d.tasks) lines.push(JSON.stringify({ type: 'task', ...t }));++ for (const r of d.rounds) lines.push(JSON.stringify({ type: 'round', ...r }));++ for (const e of d.entries) lines.push(JSON.stringify({ type: 'entry', ...e }));++ for (const j of d.judgements) lines.push(JSON.stringify({ type: 'judgement', ...j }));++ for (const r of d.ratings) lines.push(JSON.stringify({ type: 'rating', ...r }));++ return lines.length ? `${lines.join('\n')}\n` : '';++ }++++ // ── Private evals ────────────────────────────────────────++ async function createEval({ owner, kindId, name, testSuite }) {++ const kind = await getKind(kindId);++ if (!kind) return refusal(422, 'bench.kind_not_found', `No such kind: ${kindId}`);++ const cleanName = String(name == null ? '' : name).replace(/\s+/g, ' ').trim().slice(0, 160);++ if (!cleanName) return refusal(422, 'bench.eval_name_required', 'An eval needs a name.');++ const suite = Array.isArray(testSuite) ? testSuite : [];++ if (!suite.length) return refusal(422, 'bench.eval_suite_required', 'An eval needs a non-empty test_suite.');++ const id = s.newId('bev');++ const row = await db.maybe(++ `INSERT INTO bench_evals (id, owner, kind_id, name, test_suite, created_at)++ VALUES ($1, $2, $3, $4, $5::jsonb, $6::timestamptz) RETURNING *`,++ [id, owner, kind.id, cleanName, JSON.stringify(suite), s.iso()],++ );++ return { ok: true, eval: wireEval(row) };++ }++ async function getEval(id, { owner = null } = {}) {++ const row = await db.maybe('SELECT * FROM bench_evals WHERE id = $1', [String(id || '')]);++ if (!row || (owner && row.owner !== owner)) return null;++ return wireEval(row);++ }++++ async function evalDailyCount(owner) {++ const start = dayStart();++ return num(await db.value(++ `SELECT COUNT(*)::int AS n FROM bench_eval_runs r JOIN bench_evals e ON e.id = r.eval_id++ WHERE e.owner = $1 AND r.created_at >= $2::timestamptz`,++ [owner, start],++ ));++ }++++ async function runEval({ evalId, offers, owner }) {++ const ev = await getEval(evalId, { owner });++ if (!ev) throw new BenchError(404, 'bench.eval_not_found', 'No such eval (or it is not yours).');++ const used = await evalDailyCount(owner);++ const cap = config ? config.bench.evalsPerDay : 20;++ if (used >= cap) throw new BenchError(429, 'bench.eval_limit', `You can run ${cap} private evals a day.`);++ const off = Array.isArray(offers) ? offers.map((o) => ({ provider: String(o.provider || ''), model: o.model == null ? null : String(o.model) })) : [];++ const id = s.newId('ber');++ const row = await db.maybe(++ `INSERT INTO bench_eval_runs (id, eval_id, offers, status, created_at)++ VALUES ($1, $2, $3::jsonb, 'queued', $4::timestamptz) RETURNING *`,++ [id, ev.id, JSON.stringify(off), s.iso()],++ );++ return wireEvalRun(row);++ }++++ async function evalResults(evalId, { owner = null } = {}) {++ const ev = await getEval(evalId, { owner });++ if (!ev) return null;++ const runs = (await db.many('SELECT * FROM bench_eval_runs WHERE eval_id = $1 ORDER BY created_at DESC', [ev.id])).map(wireEvalRun);++ return { eval: ev, runs };++ }++++ return {++ wireKind, wireTask, wireRound, wireEntry, wireJudgement, wireRating, wireEval, wireEvalRun,++ insertOutbox, seedKinds, listKinds, getKind,++ createTask, getTask,++ createRound, getRound, addEntry, maybeReady, finishRound,++ nextJudgement, castJudgement, pairwiseGames,++ board, modelSummary, dataset, datasetJsonl,++ createEval, getEval, runEval, evalResults, evalDailyCount,++ KINDS, JUDGES, WINNERS, BASE_ELO, CATEGORY_DEFAULT, shuffle,++ };++}++++module.exports = { createStore, BenchError, KINDS, JUDGES, WINNERS, BASE_ELO, CATEGORY_DEFAULT, shuffle };nnew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++++/**++ * Bench's own origin pages (the main session designs them later): a plain home that says what Bench is, lists the++ * fifteen kinds and links to boards and docs, and /docs for the API. Ids and classes start with `bm-`.++ */++const { esc } = require('../render/html');++++function kindRow(k) {++ return `<li class="bm-kind"><a class="bm-kind-link" href="/api/v1/boards/${esc(k.id)}?type=elo">${esc(k.id)}</a><span class="bm-kind-meta">${esc(k.room)} · ${esc(k.judge)} · ${esc(k.name)}</span></li>`;++}++++function home({ kinds = [], counts = { kinds: 0 } } = {}) {++ return `<div class="bm-wrap bm-home" id="bm-home">++<header class="bm-hero"><p class="bm-kicker">OpenVibe.Bench</p><h1>One benchmark engine for every room</h1>++<p class="bm-lede">Put AI models side by side on real work: a page design, a game, a meme, a code fix, a web task. People vote blind, graders and tests score the rest, and every round is pinned to the exact model it ran, so anyone can run it again.</p>++<div class="bm-actions"><a class="bm-btn" href="/docs">Read the API</a><a class="bm-btn bm-btn-quiet" href="#bm-kinds">The kinds</a></div></header>++<ul class="bm-why" aria-label="What Bench promises">++<li><b>Free and open</b><span>The public arena costs nothing, and every kind's votes and results download as JSONL, without voter names.</span></li>++<li><b>Cost per solved task</b><span>Boards rank quality against what a solved task really cost, with confidence intervals, not a single score.</span></li>++<li><b>Every room, one engine</b><span>Design, Games, Meme, Vision, Codes, Surf, Chat and speech share one API, SDK and MCP tools.</span></li>++<li><b>Your own evals</b><span>Private task suites on your models or keys, free up to a daily cap, never published.</span></li>++</ul>++<section class="bm-section" id="bm-kinds" aria-labelledby="bm-kinds-h"><div class="bm-head-row"><h2 id="bm-kinds-h">Kinds</h2><span class="bm-count">${Number(counts.kinds) || kinds.length}</span></div>++${kinds.length ? `<ul class="bm-kinds">${kinds.map(kindRow).join('')}</ul>` : '<p class="bm-muted">No kinds are seeded yet.</p>'}</section>++<section class="bm-section" id="bm-boards"><div class="bm-head-row"><h2>Boards</h2></div>++<p class="bm-fine">Boards are JSON at <code>/api/v1/boards/:kind?type=elo|pareto|cost</code>. Public HTML boards render in OpenVibe.Report; rooms show their own arenas through Bench.</p></section>++</div>`;++}++++function docs() {++ const ep = (m, p, d) => `<div><dt><code>${esc(m)} ${esc(p)}</code></dt><dd>${d}</dd></div>`;++ return `<div class="bm-wrap bm-docs" id="bm-docs">++<header class="bm-page-h"><p class="bm-kicker">Developers</p><h1>The Bench API</h1>++<p class="bm-lede">JSON under <code>/api/v1</code>, with <code>application/problem+json</code> errors. A person is the <code>bench_at</code> session cookie or a Network user token as a Bearer; a service sends a client-credentials token with <code>bench.*</code> capabilities.</p></header>++<section><h2>Read</h2><dl class="bm-eps">++${ep('GET', '/api/v1/kinds', 'The fifteen benchmark kinds.')}++${ep('GET', '/api/v1/kinds/:kindId', 'One kind and its entry schema and metrics.')}++${ep('GET', '/api/v1/tasks/:id', 'One task.')}++${ep('GET', '/api/v1/rounds/:id', 'One round with entries; entries stay blind until the caller has judged or started the round.')}++${ep('GET', '/api/v1/boards/:kindId?type=elo|pareto|cost', 'A rendered board.')}++${ep('GET', '/api/v1/models/:model', 'One model across every kind.')}++${ep('GET', '/api/v1/datasets/:kindId.jsonl', 'The open dataset (prompts, entries, judgements without voter ids, ratings).')}++</dl></section>++<section><h2>Write</h2><dl class="bm-eps">++${ep('POST', '/api/v1/kinds/:kindId/tasks', '<code>{ prompt, task_class?, public? }</code>. A person or a service with bench.task.create.')}++${ep('POST', '/api/v1/tasks/:id/rounds', '<code>{ offers?, seed? }</code>. Pins a reproducible round; the job generates entries.')}++${ep('POST', '/api/v1/rounds/:id/entries', 'A room submits outside entries (a game match, a test-suite run) with a service token carrying bench.entry.create.')}++${ep('GET', '/api/v1/judgements/next?kind=&judge=', 'The next blind pair for this person.')}++${ep('POST', '/api/v1/rounds/:id/judgements', '<code>{ judge?, pair?, outcome?, score? }</code>. Human judges are people; automatic/test/game judges are services with bench.judgement.cast.')}++${ep('POST', '/api/v1/evals', 'Create a private task suite (never in the public dataset).')}++${ep('POST', '/api/v1/evals/:id/run', 'Run a private eval against chosen offers, under the free daily cap.')}++${ep('GET', '/api/v1/evals/:id/results', 'A private eval and its runs.')}++</dl></section>++<section><h2>MCP</h2><p class="bm-fine"><code>POST /mcp</code> exposes <code>bench.list_kinds</code>, <code>bench.start_round</code>, <code>bench.next_pair</code>, <code>bench.cast_judgement</code>, <code>bench.get_board</code> and <code>bench.run_eval</code>.</p></section>++</div>`;++}++++function notFound() {++ return `<div class="bm-wrap bm-page-pad bm-narrow"><div class="bm-empty" id="bm-not-found"><p class="bm-kicker">404</p><h1>Not here</h1><p>The page is not part of Bench's own origin. Boards are JSON under /api/v1, and the home explains the kinds.</p><a class="bm-btn" href="/">Back to Bench</a></div></div>`;++}++++module.exports = { home, docs, notFound };nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++++const assert = require('assert');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const { boot, check, done } = require('./helpers/boot');++++const SECRET = 'whsec_fixturefixturefixturefixturefixturefixture';++const EXPORT_ID = 'exp_01M624SX2FFR7RMSXMFJ2GM16F';++const DELETION_ID = 'del_01M624SX2FFR7RMSXMFJ2GM16F';++++(async () => {++ const sent = [];++ const accountSend = async (path, body) => {++ sent.push({ path, body });++ return { ok: true, status: 201 };++ };++ const t = await boot({ env: { BENCH_EVENTS_SECRET: SECRET, BENCH_ROUNDS_PER_DAY: '100', BENCH_ROUNDS_DAILY: '1000' }, accountSend });++ const alice = t.network.addUser('alice');++++ let taskId;++ let roundId;++ let judgementId;++++ const signedEvent = (event) => {++ const raw = JSON.stringify({ event });++ return { raw, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(Buffer.from(raw), SECRET) } };++ };++++ await check('a person leaves tasks, rounds and a judgement', async () => {++ const carol = t.network.addUser('carol');++ const other = await t.get('/api/v1/kinds/design.page/tasks', { as: carol, json: { prompt: 'A page for a human.', public: true } });++ const otherRound = await t.get(`/api/v1/tasks/${other.json().task.id}/rounds`, { as: carol, json: {} });++ await t.tick();++++ const task = await t.get('/api/v1/kinds/design.page/tasks', { as: alice, json: { prompt: 'A page for a robot.', public: true } });++ assert.strictEqual(task.status, 201, task.text);++ taskId = task.json().task.id;++ const rr = await t.get(`/api/v1/tasks/${taskId}/rounds`, { as: alice, json: {} });++ roundId = rr.json().round.id;++++ const pair = await t.get('/api/v1/judgements/next?kind=design.page', { as: alice });++ assert.strictEqual(pair.status, 200, pair.text);++ const vote = await t.get(`/api/v1/rounds/${otherRound.json().round.id}/judgements`, { as: alice, json: { judge: 'human_pairwise', pair: { left: pair.json().left.id, right: pair.json().right.id }, outcome: 'left' } });++ assert.strictEqual(vote.status, 201, vote.text);++ judgementId = vote.json().judgement.id;++ });++++ await check('account export sends the person\'s rows to Network', async () => {++ const ev = { event_id: 'evt_export_1', event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXPORT_ID, subject: alice.subject } };++ const r = await t.get('/internal/events', signedEvent(ev));++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().outcome, 'exported');++ const part = sent.find((c) => c.path === `/internal/account-exports/${EXPORT_ID}/parts`);++ assert.ok(part, 'export part was not sent');++ assert.strictEqual(part.body.subject, alice.subject);++ assert.ok(part.body.files.some((f) => f.name === 'bench_tasks.json' && f.content.length === 1));++ assert.ok(part.body.files.some((f) => f.name === 'bench_rounds.json' && f.content.length === 1));++ assert.ok(part.body.files.some((f) => f.name === 'bench_judgements.json' && f.content.length === 1));++ });++++ await check('account deletion removes tasks and rounds and anonymises votes', async () => {++ const ev = { event_id: 'evt_delete_1', event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DELETION_ID, subject: alice.subject } };++ const r = await t.get('/internal/events', signedEvent(ev));++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().outcome, 'erased');++ const confirm = sent.find((c) => c.path === `/internal/account-deletions/${DELETION_ID}/confirmations`);++ assert.ok(confirm, 'deletion confirmation was not sent');++ assert.strictEqual(confirm.body.subject, alice.subject);++ assert.strictEqual(confirm.body.erased.bench_tasks, 1);++ assert.strictEqual(confirm.body.erased.bench_rounds, 1);++ assert.strictEqual(confirm.body.retained.tombstones, 1);++++ const task = await t.get(`/api/v1/tasks/${taskId}`);++ assert.strictEqual(task.status, 404, task.text);++ const round = await t.get(`/api/v1/rounds/${roundId}`);++ assert.strictEqual(round.status, 404, round.text);++ const j = await t.ctx.s.db.maybe('SELECT subject FROM bench_judgements WHERE id = $1', [judgementId]);++ assert.strictEqual(j.subject, null);++ });++++ await check('stop', async () => { await t.close(); });++++ done();++})();nnew file mode 100644@@@ -0,0 +1,132 @@++'use strict';++++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const svc = t.network.serviceToken({ sub: 'svc:games', audience: 'openvibe.bench', cap: ['bench.entry.create'] });++++ let taskId;++ let roundId;++ let leftId;++ let rightId;++++ await check('kinds are seeded from code (the 15 in §4.12)', async () => {++ const r = await t.get('/api/v1/kinds');++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().kinds.length, 15);++ const ids = r.json().kinds.map((k) => k.id);++ for (const want of ['design.page', 'games.match', 'meme.caption', 'vision.answer', 'codes.patch', 'speech.tts', 'speech.asr', 'translate.ja', 'search.answer']) {++ assert.ok(ids.includes(want), `missing kind ${want}`);++ }++ });++++ await check('a person creates a task and queues a round', async () => {++ const r = await t.get('/api/v1/kinds/design.page/tasks', { as: alice, json: { prompt: 'A landing page for a tiny robot.', public: true } });++ assert.strictEqual(r.status, 201, r.text);++ taskId = r.json().task.id;++ const rr = await t.get(`/api/v1/tasks/${taskId}/rounds`, { as: alice, json: {} });++ assert.strictEqual(rr.status, 201, rr.text);++ roundId = rr.json().round.id;++ assert.strictEqual(rr.json().round.status, 'queued');++ });++++ await check('the job generates entries and finishes the round with a receipt', async () => {++ await t.tick();++ const r = await t.get(`/api/v1/rounds/${roundId}`, { as: alice });++ assert.strictEqual(r.status, 200, r.text);++ const round = r.json().round;++ assert.strictEqual(round.status, 'finished');++ assert.strictEqual(round.entries.length, 3);++ assert.ok(round.receipt && /^[0-9a-f]{64}$/.test(round.receipt), 'a round receipt is a hash');++ });++++ await check('entries are blind to a stranger and revealed to the creator', async () => {++ const anon = await t.get(`/api/v1/rounds/${roundId}`);++ assert.strictEqual(anon.json().round.blind, true);++ assert.strictEqual(anon.json().round.entries[0].model, undefined);++ const mine = await t.get(`/api/v1/rounds/${roundId}`, { as: alice });++ assert.ok(mine.json().round.entries[0].model, 'creator sees the model');++ });++++ await check('next pair is blind and excludes the creator', async () => {++ const none = await t.get('/api/v1/judgements/next?kind=design.page', { as: alice });++ assert.strictEqual(none.json().pair, null);++ const r = await t.get('/api/v1/judgements/next?kind=design.page', { as: bob });++ assert.strictEqual(r.status, 200, r.text);++ const pair = r.json();++ assert.ok(pair.left && pair.right && pair.left.id !== pair.right.id);++ assert.strictEqual(pair.left.model, undefined);++ leftId = pair.left.id;++ rightId = pair.right.id;++ });++++ await check('a blind vote records, reveals, updates Elo and enforces one vote', async () => {++ const r = await t.get(`/api/v1/rounds/${roundId}/judgements`, { as: bob, json: { pair: { left: leftId, right: rightId }, outcome: 'left' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.ok(r.json().revealed.left.model, 'revealed after the vote');++ const again = await t.get(`/api/v1/rounds/${roundId}/judgements`, { as: bob, json: { pair: { left: leftId, right: rightId }, outcome: 'right' } });++ assert.strictEqual(again.status, 409, again.text);++ });++++ await check('a creator cannot vote on their own round', async () => {++ const r = await t.get(`/api/v1/rounds/${roundId}/judgements`, { as: alice, json: { pair: { left: leftId, right: rightId }, outcome: 'left' } });++ assert.strictEqual(r.status, 403, r.text);++ });++++ await check('elo, pareto and cost boards render as JSON', async () => {++ for (const type of ['elo', 'pareto', 'cost']) {++ const r = await t.get(`/api/v1/boards/design.page?type=${type}`);++ assert.strictEqual(r.status, 200, `${type}: ${r.text}`);++ assert.strictEqual(r.json().board.type, type);++ assert.ok(Array.isArray(r.json().board.rows));++ }++ });++++ await check('the open dataset has no voter ids', async () => {++ const r = await t.get('/api/v1/datasets/design.page.jsonl');++ assert.strictEqual(r.status, 200, r.text);++ const lines = r.text.trim().split('\n').map((l) => JSON.parse(l));++ const judgements = lines.filter((l) => l.type === 'judgement');++ assert.ok(judgements.length >= 1);++ for (const j of judgements) assert.strictEqual(j.subject, undefined);++ });++++ await check('a model page aggregates ratings across kinds', async () => {++ const reveal = await t.get(`/api/v1/rounds/${roundId}`, { as: alice });++ const model = reveal.json().round.entries[0].model;++ const r = await t.get(`/api/v1/models/${encodeURIComponent(model)}`);++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().model, model);++ assert.ok(Array.isArray(r.json().entries));++ });++++ await check('a room service submits entries with a bench.entry.create token', async () => {++ const rr = await t.get(`/api/v1/tasks/${taskId}/rounds`, { as: alice, json: {} });++ const rid = rr.json().round.id;++ const e1 = await t.get(`/api/v1/rounds/${rid}/entries`, { bearer: svc, json: { provider: 'acme', model: 'alpha-1', payload: { html: '<h1>One</h1>' } } });++ assert.strictEqual(e1.status, 201, e1.text);++ const e2 = await t.get(`/api/v1/rounds/${rid}/entries`, { bearer: svc, json: { provider: 'beta', model: 'beta-2', payload: { html: '<h1>Two</h1>' } } });++ assert.strictEqual(e2.status, 201, e2.text);++ const round = await t.get(`/api/v1/rounds/${rid}`, { as: alice });++ assert.strictEqual(round.json().round.status, 'finished');++ assert.strictEqual(round.json().round.entries.length, 2);++ });++++ await check('a person\'s token is refused for outside entries', async () => {++ const rr = await t.get(`/api/v1/tasks/${taskId}/rounds`, { as: alice, json: {} });++ const rid = rr.json().round.id;++ const person = await t.get(`/api/v1/rounds/${rid}/entries`, { as: alice, json: { provider: 'acme', model: 'alpha-1', payload: { html: '<h1>No</h1>' } } });++ assert.strictEqual(person.status, 403, person.text);++ assert.strictEqual(person.json().code, 'bench.capability_required');++ const noCap = await t.get(`/api/v1/rounds/${rid}/entries`, { bearer: t.network.serviceToken({ sub: 'svc:nope', audience: 'openvibe.bench', cap: [] }), json: { provider: 'acme', model: 'alpha-1', payload: { html: '<h1>No</h1>' } } });++ assert.strictEqual(noCap.status, 403, noCap.text);++ });++++ await check('stop', async () => { await t.close(); });++++ done();++})();nnew file mode 100644@@@ -0,0 +1,73 @@++'use strict';++++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot({ env: { BENCH_ROUNDS_PER_DAY: '100', BENCH_ROUNDS_DAILY: '1000' } });++ const alice = t.network.addUser('alice');++ const entrySvc = t.network.serviceToken({ sub: 'svc:room', audience: 'openvibe.bench', cap: ['bench.entry.create'] });++ const judgeSvc = t.network.serviceToken({ sub: 'svc:judge', audience: 'openvibe.bench', cap: ['bench.judgement.cast'] });++++ let roundId;++ let leftId;++ let rightId;++++ await check('elo, pareto and cost boards render for a seeded kind', async () => {++ const task = await t.get('/api/v1/kinds/design.page/tasks', { as: alice, json: { prompt: 'A page for a robot.', public: true } });++ const rr = await t.get(`/api/v1/tasks/${task.json().task.id}/rounds`, { as: alice, json: {} });++ roundId = rr.json().round.id;++ const e1 = await t.get(`/api/v1/rounds/${roundId}/entries`, { bearer: entrySvc, json: { provider: 'acme', model: 'alpha-1', payload: { html: '<h1>One</h1>' } } });++ const e2 = await t.get(`/api/v1/rounds/${roundId}/entries`, { bearer: entrySvc, json: { provider: 'beta', model: 'beta-2', payload: { html: '<h1>Two</h1>' } } });++ assert.strictEqual(e1.status, 201, e1.text);++ assert.strictEqual(e2.status, 201, e2.text);++ leftId = e1.json().entry.id;++ rightId = e2.json().entry.id;++ for (const type of ['elo', 'pareto', 'cost']) {++ const r = await t.get(`/api/v1/boards/design.page?type=${type}`);++ assert.strictEqual(r.status, 200, `${type}: ${r.text}`);++ assert.strictEqual(r.json().board.type, type);++ assert.ok(Array.isArray(r.json().board.rows));++ }++ });++++ await check('a board marks models with under 10 games as provisional', async () => {++ let board = await t.get('/api/v1/boards/design.page?type=elo');++ const subjects = board.json().board.rows;++ assert.ok(subjects.length >= 2, 'expected at least two subjects');++ for (const row of subjects) assert.strictEqual(row.provisional, true, `${row.subject} should be provisional with ${row.games} games`);++ await t.get(`/api/v1/rounds/${roundId}/judgements`, { bearer: judgeSvc, json: { judge: 'game_outcome', pair: { left: leftId, right: rightId }, outcome: 'left' } });++ board = await t.get('/api/v1/boards/design.page?type=elo');++ const after = board.json().board.rows;++ assert.ok(after.some((row) => row.games === 1 && row.provisional === true), 'one game is still provisional');++ });++++ await check('a model page aggregates across kinds', async () => {++ const task = await t.get('/api/v1/kinds/games.match/tasks', { as: alice, json: { prompt: 'A match.', public: true } });++ const rr = await t.get(`/api/v1/tasks/${task.json().task.id}/rounds`, { as: alice, json: {} });++ const rid = rr.json().round.id;++ await t.get(`/api/v1/rounds/${rid}/entries`, { bearer: entrySvc, json: { provider: 'acme', model: 'alpha-1', payload: { result: { winner: 'left' } } } });++ await t.get(`/api/v1/rounds/${rid}/entries`, { bearer: entrySvc, json: { provider: 'beta', model: 'beta-2', payload: { result: { winner: 'right' } } } });++ const r = await t.get('/api/v1/models/alpha-1');++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().model, 'alpha-1');++ assert.ok(Array.isArray(r.json().entries));++ assert.ok(r.json().entries.some((e) => e.kind_id === 'design.page'));++ assert.ok(r.json().entries.some((e) => e.kind_id === 'games.match'));++ assert.ok(Array.isArray(r.json().ratings));++ });++++ await check('the open dataset has no voter id or person id', async () => {++ const r = await t.get('/api/v1/datasets/design.page.jsonl');++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.text.includes('usr_'), false, 'dataset contains a person id');++ for (const line of r.text.trim().split('\n').map((l) => JSON.parse(l))) {++ assert.strictEqual(line.created_by, undefined, 'task or round leaked a person id');++ if (line.type === 'judgement') assert.strictEqual(line.subject, undefined, 'judgement leaked a voter id');++ }++ });++++ await check('stop', async () => { await t.close(); });++++ done();++})();nnew file mode 100644@@@ -0,0 +1,46 @@++'use strict';++++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot({ env: { BENCH_EVALS_PER_DAY: '1' } });++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++++ let evalId;++ let runId;++++ await check('a private eval is created and run under the free daily cap', async () => {++ const r = await t.get('/api/v1/evals', { as: alice, json: { kind_id: 'vision.answer', name: 'Held-out suite', test_suite: [{ prompt: 'What is 2 + 2?' }, { prompt: 'What is 3 + 3?' }] } });++ assert.strictEqual(r.status, 201, r.text);++ evalId = r.json().eval.id;++ const run = await t.get(`/api/v1/evals/${evalId}/run`, { as: alice, json: { offers: [{ provider: 'acme', model: 'alpha-1' }] } });++ assert.strictEqual(run.status, 202, run.text);++ runId = run.json().run.id;++ assert.strictEqual(run.json().run.status, 'queued');++ });++++ await check('the daily cap refuses a second run the same day', async () => {++ const run = await t.get(`/api/v1/evals/${evalId}/run`, { as: alice, json: { offers: [] } });++ assert.strictEqual(run.status, 429, run.text);++ });++++ await check('another owner cannot read the eval', async () => {++ const r = await t.get(`/api/v1/evals/${evalId}`, { as: bob });++ assert.strictEqual(r.status, 404, r.text);++ });++++ await check('the job runs the suite and results stay private', async () => {++ await t.tick();++ const r = await t.get(`/api/v1/evals/${evalId}/results`, { as: alice });++ assert.strictEqual(r.status, 200, r.text);++ const run = r.json().runs.find((x) => x.id === runId);++ assert.strictEqual(run.status, 'ready');++ assert.ok(Array.isArray(run.results));++ });++++ await check('stop', async () => { await t.close(); });++++ done();++})();nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot({ env: { BENCH_ROUNDS_PER_DAY: '100', BENCH_ROUNDS_DAILY: '1000' } });++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++++ let roundId;++++ const topics = async () => (await t.ctx.s.db.many('SELECT topic, payload FROM bench_outbox ORDER BY created_at, event_id')).map((r) => r.topic);++++ await check('the outbox writes bench.round.finished when a round finishes', async () => {++ const task = await t.get('/api/v1/kinds/design.page/tasks', { as: alice, json: { prompt: 'A page for a robot.', public: true } });++ const rr = await t.get(`/api/v1/tasks/${task.json().task.id}/rounds`, { as: alice, json: {} });++ roundId = rr.json().round.id;++ await t.tick();++ assert.ok((await topics()).includes('bench.round.finished'), 'expected bench.round.finished');++ });++++ await check('the outbox writes bench.judgement.recorded and bench.rating.changed for a pairwise vote', async () => {++ const pair = await t.get('/api/v1/judgements/next?kind=design.page', { as: bob });++ assert.strictEqual(pair.status, 200, pair.text);++ const vote = await t.get(`/api/v1/rounds/${roundId}/judgements`, { as: bob, json: { judge: 'human_pairwise', pair: { left: pair.json().left.id, right: pair.json().right.id }, outcome: 'left' } });++ assert.strictEqual(vote.status, 201, vote.text);++ const all = await topics();++ assert.ok(all.includes('bench.judgement.recorded'), 'expected bench.judgement.recorded');++ assert.ok(all.includes('bench.rating.changed'), 'expected bench.rating.changed');++ });++++ await check('stop', async () => { await t.close(); });++++ done();++})();nnew file mode 100644@@@ -0,0 +1,107 @@++'use strict';++/**++ * An in-process stand-in for OpenVibe.AI: GET /api/v1/routes?operation=generate lists offers, POST /api/v1/generate++ * answers with a fake run. It verifies the client-credentials token Bench got from the Network stub (audience++ * openvibe.ai, capability ai.run.create, namespace ai.generate) and records every request.++ */++const http = require('http');++const crypto = require('crypto');++++const DEFAULT_MODELS = [++ { provider: 'acme', model: 'alpha-1' },++ { provider: 'beta', model: 'beta-2' },++ { provider: 'gamma', model: 'gamma-3' },++];++++function payload(token) {++ const part = String(token || '').split('.')[1];++ if (!part) return null;++ try { return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')); } catch { return null; }++}++++function listen(handler) {++ return new Promise((resolve) => {++ const sockets = new Set();++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks);++ const json = (status, obj, headers = {}) => { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); };++ Promise.resolve(handler(req, raw, json, res)).catch((err) => json(500, { code: 'stub.error', detail: String(err && err.message) }));++ });++ });++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });++ server.listen(0, '127.0.0.1', () => resolve({++ server, url: `http://127.0.0.1:${server.address().port}`,++ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),++ }));++ });++}++++async function startAi({ models = DEFAULT_MODELS, costUsd = 0.0012, ms = 321, tokensIn = 120, tokensOut = 480, textFor = null } = {}) {++ const st = { models: models.map((m) => ({ ...m })), requests: [], runCount: 0, runs: new Map(), costUsd, ms, tokensIn, tokensOut, textFor };++++ function modelFor(body) {++ const asked = body && body.route;++ if (asked && typeof asked === 'object') {++ const hit = st.models.find((x) => x.provider === asked.provider && (asked.model == null || x.model === asked.model));++ if (hit) return hit;++ return false;++ }++ return st.models[st.runCount % st.models.length] || { provider: 'stub', model: 'stub-1' };++ }++++ const srv = await listen(async (req, raw, json) => {++ const url = new URL(req.url, 'http://x');++ const text = raw.toString('utf8');++ let body = {};++ try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }++ const auth = String(req.headers.authorization || '');++ const claims = auth.startsWith('Bearer ') ? payload(auth.slice(7)) : null;++ if (!claims || !(claims.aud || []).includes('openvibe.ai') || !(claims.cap || []).includes('ai.run.create')) {++ return json(401, { code: 'token.invalid', detail: 'a Bench client-credentials token with ai.run.create is required' });++ }++ if (!(claims.ns || []).some((n) => n === 'ai.generate' || n === 'ai.*')) {++ return json(403, { code: 'namespace.denied', detail: 'the token needs the ai.generate namespace' });++ }++++ if (url.pathname === '/api/v1/routes' && req.method === 'GET') {++ st.requests.push({ method: 'GET', path: url.pathname, body });++ const [first, ...rest] = st.models;++ return json(200, { routes: [{ key: 'default.chat', version: 3, status: 'active', capability: 'chat', primary: { provider: first.provider, model: first.model }, fallbacks: rest.map((m) => ({ provider: m.provider, model: m.model })), pinned: [], constraints: {}, offers: st.models.map((m) => ({ provider: m.provider, model: m.model })) }] });++ }++++ if (url.pathname === '/api/v1/generate' && req.method === 'POST') {++ st.requests.push({ method: 'POST', path: url.pathname, body, claims });++ const pick = modelFor(body);++ if (pick === false) return json(422, { code: 'input.invalid', detail: `route ${body.route.provider}/${body.route.model} is not offered` });++ const id = `run_${crypto.randomBytes(8).toString('hex')}`;++ const make = st.textFor || textFor;++ const text = make ? make(body, pick, st) : `<!doctype html><html><body><h1>Generated output</h1><p>${String(body.prompt || '').split('\n')[0].slice(0, 200)}</p></body></html>`;++ const run = {++ id, status: 'succeeded', workflow: { key: 'ai.generate', version: 1 },++ provenance: { provider: pick.provider, model: pick.model, run_id: id },++ output: { text },++ usage: { tokens_in: st.tokensIn, tokens_out: st.tokensOut, cost_usd: st.costUsd, attempts: 1 },++ started_at: new Date(Date.now() - st.ms).toISOString(), finished_at: new Date().toISOString(), created_at: new Date().toISOString(),++ };++ st.runs.set(id, run);++ st.runCount++;++ return json(201, { run });++ }++++ const one = /^\/api\/v1\/runs\/([^/]+)$/.exec(url.pathname);++ if (one && req.method === 'GET') {++ const run = st.runs.get(decodeURIComponent(one[1]));++ if (!run) return json(404, { code: 'run.not_found' });++ return json(200, { run });++ }++++ return json(404, { code: 'not_found', detail: 'not found' });++ });++++ return { url: srv.url, state: st, models: st.models, requests: st.requests, close: srv.close };++}++++module.exports = { startAi, DEFAULT_MODELS };nnew file mode 100644@@@ -0,0 +1,106 @@++'use strict';++// Many requests per test file: the per-address limits would answer 429 part-way.++process.env.BENCH_RATE_LIMIT_PER_MIN = process.env.BENCH_RATE_LIMIT_PER_MIN || '100000';++process.env.BENCH_API_RATE_LIMIT_PER_MIN = process.env.BENCH_API_RATE_LIMIT_PER_MIN || '100000';++process.env.BENCH_LIMITS_MINUTE = process.env.BENCH_LIMITS_MINUTE || '100000';++process.env.BENCH_LIMITS_HOUR = process.env.BENCH_LIMITS_HOUR || '100000';++/**++ * Boots OpenVibe.Bench on a temp database with a mock Network and a fake AI, captures log lines, and returns a++ * small HTTP client. Every test file gets its own process, database, Network, AI and app.++ */++const http = require('http');++const { startNetwork } = require('./network');++const { startAi } = require('./ai');++++async function boot(opts = {}) {++ const network = await startNetwork(opts.network || {});++ const ai = opts.ai || await startAi(opts.aiOptions || {});++ const env = {++ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://bench.openvibe.services', TRUST_PROXY: '1',++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url, OV_NETWORK_ISSUER: network.url,++ OV_OAUTH_CLIENT_ID: 'bench', OV_OAUTH_CLIENT_SECRET: 'bench-secret', COOKIE_SECURE: 'false',++ OV_AI_INTERNAL_URL: ai.url,++ BENCH_DRILL: '1',++ BENCH_AI_POLL_MS: '10', BENCH_AI_WAIT_MS: '5000', BENCH_JOB_INTERVAL_MS: '100000',++ ...(opts.env || {}),++ };++ for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];++++ const logLines = [];++ const log = {++ log: (...a) => logLines.push(a.map(String).join(' ')),++ info: (...a) => logLines.push(a.map(String).join(' ')),++ warn: (...a) => logLines.push(a.map(String).join(' ')),++ error: (...a) => logLines.push(a.map(String).join(' ')),++ };++++ const configLib = require('../../server/config');++ const { createApp } = require('../../server/app');++ const { createStore } = require('../../server/db');++ const config = configLib.load(env);++ const testdb = opts.store ? null : await require('./db').testDb({ store: opts.testStore });++ const store = opts.store || createStore(testdb.db, { now: opts.now });++ const built = await createApp({ config, store, benchAi: opts.benchAi, log, now: opts.now, accountSend: opts.accountSend, random: opts.random, telemetrySink: opts.telemetrySink });++ await built.ctx.keys.ensure();++ const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++++ const signIn = (user) => `bench_at=${network.userToken(user)}`;++++ async function get(p, o = {}) {++ const headers = { ...(o.headers || {}) };++ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');++ if (o.cookie) headers.cookie = o.cookie;++ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;++ let body = o.body;++ if (o.raw !== undefined) { body = o.raw; }++ else if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }++ const method = o.method || (body ? 'POST' : 'GET');++ if (o.as && method !== 'GET' && method !== 'HEAD' && !headers.origin && !headers['sec-fetch-site']) headers.origin = config.baseUrl;++ if (o.origin) headers.origin = o.origin;++ if (o.secFetchSite) headers['sec-fetch-site'] = o.secFetchSite;++ const res = await fetch(base + p, { method, headers, body, redirect: 'manual' });++ const buf = Buffer.from(await res.arrayBuffer());++ const text = buf.toString('utf8');++ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };++ }++++ async function dbDump() {++ const db = built.ctx.s.db;++ const tables = (await db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()")).map((r) => r.name);++ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.many(`SELECT * FROM "${tname}"`))))).join('\n');++ }++++ return {++ base, network, ai, config, ctx: built.ctx, store: built.ctx.s, get, signIn, dbDump,++ tick: () => built.ctx.jobs.tick(),++ logs: () => logLines.join('\n'),++ async close() {++ built.ctx.jobs.stop();++ built.ctx.publisher.stop();++ await new Promise((r) => server.close(r));++ built.ctx.keys.client.stop();++ await built.ctx.telemetry.stop().catch(() => {});++ if (testdb) await testdb.close();++ await network.close();++ if (!opts.ai) await ai.close();++ },++ };++}++++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, 10).join('\n ')}\n`);++ }++}++function done() {++ process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n');++ process.exit(failures ? 1 : 0);++}++++module.exports = { boot, check, done };nnew file mode 100644@@@ -0,0 +1,11 @@++'use strict';++/**++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with++ * BENCH_TEST_STORE=pg the PostgreSQL + PgBouncer containers.++ */++const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');++const { MIGRATIONS } = require('../../server/db');++++const testDb = ({ store = process.env.BENCH_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'bench', max });++++module.exports = { testDb, pgAvailable };nnew file mode 100644@@@ -0,0 +1,127 @@++'use strict';++/**++ * An in-process stand-in for OpenVibe.Network with a real RS256 key pair: JWKS, /oauth/token (authorization_code +++ * PKCE S256 for client `bench`, refresh_token, client_credentials), /oauth/revoke. Every request is recorded.++ */++const http = require('http');++const crypto = require('crypto');++const { serviceAuth, ids } = require('openvibe-contracts');++++function listen(handler) {++ return new Promise((resolve) => {++ const sockets = new Set();++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks);++ const json = (status, obj, headers = {}) => { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); };++ Promise.resolve(handler(req, raw, json, res)).catch((err) => { json(500, { error: String(err && err.message) }); });++ });++ });++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });++ server.listen(0, '127.0.0.1', () => resolve({++ server, url: `http://127.0.0.1:${server.address().port}`,++ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),++ }));++ });++}++++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');++function signJwt(claims, privateKey) {++ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}`;++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;++}++++async function startNetwork() {++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });++ const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], down: false };++ let issuer = null;++ let nextUserId = 1;++++ const problem = (json, status, code, detail) => json(status, { type: `https://openvibe.network/problems/${code}`, title: 'Error', status, code, detail, error: detail, request_id: `req_${crypto.randomBytes(6).toString('hex')}` }, { 'Content-Type': 'application/problem+json' });++++ function addUser(username, { role = 'user' } = {}) {++ const u = { id: nextUserId++, subject: ids.newId('user'), username, display_name: username, role };++ st.users.set(u.id, u); st.byUsername.set(username, u);++ return u;++ }++ function userToken(u, { ttl = 3600, iat = null } = {}) {++ const now = iat != null ? iat : Math.floor(Date.now() / 1000);++ return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);++ }++ function serviceToken({ sub = 'svc:test', audience = 'openvibe.bench', cap = [], ns = [], ttl = 300 } = {}) {++ const now = Math.floor(Date.now() / 1000);++ return signJwt({ iss: issuer, sub, actor_type: 'service', aud: [audience], cap, ns, iat: now, exp: now + ttl, jti: crypto.randomUUID() }, privatePem);++ }++ function userFrom(req) {++ const h = String(req.headers.authorization || '');++ if (!h.startsWith('Bearer ')) return null;++ const parts = h.slice(7).split('.');++ if (parts.length !== 3) return null;++ const ok = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicPem, Buffer.from(parts[2], 'base64url'));++ if (!ok) return null;++ const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));++ if (c.exp * 1000 < Date.now()) return 'expired';++ return st.users.get(c.sub) || null;++ }++++ const srv = await listen(async (req, raw, json) => {++ const url = new URL(req.url, 'http://x');++ st.requests.push({ method: req.method, path: url.pathname, query: url.search, headers: req.headers, body: raw.toString('utf8') });++ if (st.down) return problem(json, 503, 'network.down', 'Network is restarting');++++ if (url.pathname === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });++ if (url.pathname === '/oauth/revoke') return json(200, {});++ if (url.pathname === '/oauth/token' && req.method === 'POST') {++ const ct = String(req.headers['content-type'] || '');++ const body = ct.includes('json') ? JSON.parse(raw.toString('utf8') || '{}') : Object.fromEntries(new URLSearchParams(raw.toString('utf8')));++ st.tokenRequests.push({ grant_type: body.grant_type, client_id: body.client_id, audience: body.audience, scope: body.scope });++ if (body.client_id !== 'bench' || body.client_secret !== 'bench-secret') return json(401, { error: 'invalid_client', error_description: 'bad client secret' });++ if (body.grant_type === 'authorization_code') {++ const c = st.codes.get(body.code);++ if (!c) return json(400, { error: 'invalid_grant', error_description: 'unknown code' });++ st.codes.delete(body.code);++ const challenge = crypto.createHash('sha256').update(String(body.code_verifier || '')).digest('base64url');++ if (challenge !== c.challenge) return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });++ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;++ st.refresh.set(rt, c.user);++ return json(200, { access_token: userToken(c.user), refresh_token: rt, token_type: 'Bearer' });++ }++ if (body.grant_type === 'refresh_token') {++ const user = st.refresh.get(body.refresh_token);++ if (!user) return json(400, { error: 'invalid_grant', error_description: 'refresh token rejected' });++ st.refresh.delete(body.refresh_token);++ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;++ st.refresh.set(rt, user);++ return json(200, { access_token: userToken(user), refresh_token: rt, token_type: 'Bearer' });++ }++ if (body.grant_type === 'client_credentials') {++ const now = Math.floor(Date.now() / 1000);++ const cap = String(body.scope || '').split(/\s+/).filter(Boolean);++ const audience = body.audience || 'openvibe.bench';++ const ns = audience === 'openvibe.ai' ? ['ai.generate'] : [];++ return json(200, {++ access_token: serviceAuth.signServiceToken({ iss: issuer, sub: 'svc:bench', actor_type: 'service', aud: [audience], cap, ns, iat: now, exp: now + 300, jti: crypto.randomUUID() }, privatePem),++ token_type: 'Bearer', expires_in: 300, scope: cap.join(' '),++ });++ }++ return json(400, { error: 'unsupported_grant_type' });++ }++++ return json(404, { error: 'not found' });++ });++ issuer = srv.url;++++ return {++ url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,++ addUser, userToken, serviceToken, signJwt: (claims) => signJwt(claims, privatePem), issuer: () => issuer,++ issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },++ setDown(v) { st.down = v; },++ close: srv.close,++ };++}++++module.exports = { startNetwork, signJwt };nnew file mode 100644@@@ -0,0 +1,113 @@++'use strict';++++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot({ env: { BENCH_ROUNDS_PER_DAY: '100', BENCH_ROUNDS_DAILY: '1000' } });++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const entrySvc = t.network.serviceToken({ sub: 'svc:room', audience: 'openvibe.bench', cap: ['bench.entry.create'] });++ const judgeSvc = t.network.serviceToken({ sub: 'svc:judge', audience: 'openvibe.bench', cap: ['bench.judgement.cast'] });++++ let winRound;++ let winPair;++++ async function makeRound(kindId = 'design.page', models = ['acme/alpha-1', 'beta/beta-2']) {++ const task = await t.get(`/api/v1/kinds/${kindId}/tasks`, { as: alice, json: { prompt: 'A page for a robot.', public: true } });++ assert.strictEqual(task.status, 201, task.text);++ const rr = await t.get(`/api/v1/tasks/${task.json().task.id}/rounds`, { as: alice, json: {} });++ assert.strictEqual(rr.status, 201, rr.text);++ const roundId = rr.json().round.id;++ const ids = [];++ for (const name of models) {++ const [provider, model] = name.split('/');++ const e = await t.get(`/api/v1/rounds/${roundId}/entries`, {++ bearer: entrySvc,++ json: { provider, model, payload: { html: `<h1>${model}</h1>` } },++ });++ assert.strictEqual(e.status, 201, e.text);++ ids.push(e.json().entry.id);++ }++ return { roundId, entryIds: ids };++ }++++ await check('human pairwise: a win is blind before the vote and revealed after it', async () => {++ const { roundId } = await makeRound();++ const pair = await t.get('/api/v1/judgements/next?kind=design.page', { as: bob });++ assert.strictEqual(pair.status, 200, pair.text);++ assert.strictEqual(pair.json().left.model, undefined, 'blind before the vote');++ assert.strictEqual(pair.json().right.model, undefined, 'blind before the vote');++ const vote = await t.get(`/api/v1/rounds/${roundId}/judgements`, { as: bob, json: { judge: 'human_pairwise', pair: { left: pair.json().left.id, right: pair.json().right.id }, outcome: 'left' } });++ assert.strictEqual(vote.status, 201, vote.text);++ assert.ok(vote.json().revealed.left.model, 'revealed after the vote');++ assert.ok(vote.json().revealed.right.model, 'revealed after the vote');++ winRound = roundId;++ winPair = { left: pair.json().left.id, right: pair.json().right.id };++ });++++ await check('human pairwise: one vote per person per round', async () => {++ const again = await t.get(`/api/v1/rounds/${winRound}/judgements`, { as: bob, json: { judge: 'human_pairwise', pair: winPair, outcome: 'right' } });++ assert.strictEqual(again.status, 409, again.text);++ });++++ await check('human pairwise: the creator cannot vote on their own round', async () => {++ const vote = await t.get(`/api/v1/rounds/${winRound}/judgements`, { as: alice, json: { judge: 'human_pairwise', pair: winPair, outcome: 'left' } });++ assert.strictEqual(vote.status, 403, vote.text);++ });++++ await check('human pairwise: a tie is recorded', async () => {++ const { roundId } = await makeRound();++ const pair = await t.get('/api/v1/judgements/next?kind=design.page', { as: bob });++ const vote = await t.get(`/api/v1/rounds/${roundId}/judgements`, { as: bob, json: { judge: 'human_pairwise', pair: { left: pair.json().left.id, right: pair.json().right.id }, outcome: 'tie' } });++ assert.strictEqual(vote.status, 201, vote.text);++ assert.strictEqual(vote.json().judgement.outcome, 'tie');++ });++++ await check('human pairwise: both_bad is recorded and does not change ratings', async () => {++ const { roundId } = await makeRound();++ const pair = await t.get('/api/v1/judgements/next?kind=design.page', { as: bob });++ const vote = await t.get(`/api/v1/rounds/${roundId}/judgements`, { as: bob, json: { judge: 'human_pairwise', pair: { left: pair.json().left.id, right: pair.json().right.id }, outcome: 'both_bad' } });++ assert.strictEqual(vote.status, 201, vote.text);++ assert.strictEqual(vote.json().judgement.outcome, 'both_bad');++ });++++ await check('rubric: a person casts a score and outcome', async () => {++ const { roundId } = await makeRound();++ const vote = await t.get(`/api/v1/rounds/${roundId}/judgements`, { as: alice, json: { judge: 'rubric', judge_name: 'rubric-v1', judge_version: '1.0.0', score: 0.75, outcome: 'pass' } });++ assert.strictEqual(vote.status, 201, vote.text);++ assert.strictEqual(vote.json().judgement.judge, 'rubric');++ assert.strictEqual(vote.json().judgement.score, 0.75);++ assert.strictEqual(vote.json().judgement.outcome, 'pass');++ });++++ await check('automatic grader: name and version are recorded', async () => {++ const { roundId } = await makeRound('vision.answer');++ const vote = await t.get(`/api/v1/rounds/${roundId}/judgements`, { bearer: judgeSvc, json: { judge: 'automatic_grader', judge_name: 'vision-grader', judge_version: 'v3.2.1', score: 1, outcome: 'pass' } });++ assert.strictEqual(vote.status, 201, vote.text);++ assert.strictEqual(vote.json().judgement.judge, 'automatic_grader');++ assert.strictEqual(vote.json().judgement.judge_name, 'vision-grader');++ assert.strictEqual(vote.json().judgement.judge_version, 'v3.2.1');++ });++++ await check('test suite: name and version are recorded', async () => {++ const { roundId } = await makeRound('codes.patch');++ const vote = await t.get(`/api/v1/rounds/${roundId}/judgements`, { bearer: judgeSvc, json: { judge: 'test_suite', judge_name: 'patch-tests', judge_version: 'v2.0.0', score: 3, outcome: 'pass' } });++ assert.strictEqual(vote.status, 201, vote.text);++ assert.strictEqual(vote.json().judgement.judge, 'test_suite');++ assert.strictEqual(vote.json().judgement.judge_name, 'patch-tests');++ assert.strictEqual(vote.json().judgement.judge_version, 'v2.0.0');++ });++++ await check('game outcome: a service casts a draw from a pair', async () => {++ const { roundId, entryIds } = await makeRound('games.match', ['acme/alpha-1', 'beta/beta-2']);++ const vote = await t.get(`/api/v1/rounds/${roundId}/judgements`, { bearer: judgeSvc, json: { judge: 'game_outcome', pair: { left: entryIds[0], right: entryIds[1] }, outcome: 'draw' } });++ assert.strictEqual(vote.status, 201, vote.text);++ assert.strictEqual(vote.json().judgement.judge, 'game_outcome');++ assert.strictEqual(vote.json().judgement.outcome, 'draw');++ });++++ await check('stop', async () => { await t.close(); });++++ done();++})();nnew file mode 100644@@@ -0,0 +1,125 @@++'use strict';++++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const rpc = (method, params, id = 1) => ({ jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) });++++(async () => {++ const t = await boot({ env: { BENCH_ROUNDS_PER_DAY: '100', BENCH_ROUNDS_DAILY: '1000' } });++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++++ let roundId;++ let leftId;++ let rightId;++++ await check('initialize negotiates and names the server', async () => {++ const r = await t.get('/mcp', { json: rpc('initialize', { protocolVersion: '2025-06-18' }, 1) });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().result.serverInfo.name, 'openvibe-bench');++ assert.strictEqual(r.json().result.protocolVersion, '2025-06-18');++ });++++ await check('initialize falls back to the current protocol for an unknown version', async () => {++ const r = await t.get('/mcp', { json: rpc('initialize', { protocolVersion: '2099-01-01' }, 2) });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().result.protocolVersion, '2025-06-18');++ });++++ await check('ping answers an empty result', async () => {++ const r = await t.get('/mcp', { json: rpc('ping', {}, 3) });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.json().result, {});++ });++++ await check('tools/list has the six §4.12 tools', async () => {++ const r = await t.get('/mcp', { json: rpc('tools/list', {}, 4) });++ const names = r.json().result.tools.map((x) => x.name);++ assert.deepStrictEqual(names, ['bench.list_kinds', 'bench.start_round', 'bench.next_pair', 'bench.cast_judgement', 'bench.get_board', 'bench.run_eval']);++ });++++ await check('an unknown method and a malformed message are JSON-RPC errors', async () => {++ const unknown = await t.get('/mcp', { json: rpc('does.not_exist', {}, 5) });++ assert.strictEqual(unknown.status, 200, unknown.text);++ assert.strictEqual(unknown.json().error.code, -32601);++ const malformed = await t.get('/mcp', { json: { jsonrpc: '2.0', id: 6, method: 3 } });++ assert.strictEqual(malformed.status, 200, malformed.text);++ assert.strictEqual(malformed.json().error.code, -32600);++ });++++ await check('an unknown tool is a JSON-RPC error', async () => {++ const r = await t.get('/mcp', { json: rpc('tools/call', { name: 'bench.nope', arguments: {} }, 7) });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().error.code, -32602);++ assert.strictEqual(r.json().error.message, 'Unknown tool');++ });++++ await check('bench.list_kinds lists the fifteen kinds', async () => {++ const r = await t.get('/mcp', { json: rpc('tools/call', { name: 'bench.list_kinds', arguments: {} }, 8) });++ assert.strictEqual(r.json().result.isError, false);++ assert.strictEqual(r.json().result.structuredContent.kinds.length, 15);++ });++++ await check('bench.get_board renders a board without a person', async () => {++ const r = await t.get('/mcp', { json: rpc('tools/call', { name: 'bench.get_board', arguments: { kind: 'design.page', type: 'elo' } }, 9) });++ assert.strictEqual(r.json().result.isError, false);++ assert.strictEqual(r.json().result.structuredContent.board.type, 'elo');++ });++++ await check('bench.get_board reports a missing kind as a tool error', async () => {++ const r = await t.get('/mcp', { json: rpc('tools/call', { name: 'bench.get_board', arguments: { kind: 'no.such', type: 'elo' } }, 10) });++ assert.strictEqual(r.json().result.isError, true);++ assert.match(r.json().result.content[0].text, /No such kind/);++ });++++ await check('a person-only tool is a tool error when anonymous', async () => {++ for (const call of [++ { name: 'bench.next_pair', arguments: { kind: 'design.page' } },++ { name: 'bench.cast_judgement', arguments: { round: 'brd_nope', left: 'bte_1', right: 'bte_2', outcome: 'left' } },++ { name: 'bench.start_round', arguments: { kind: 'design.page', prompt: 'x' } },++ { name: 'bench.run_eval', arguments: { kind: 'vision.answer', test_suite: [{ prompt: 'x' }] } },++ ]) {++ const r = await t.get('/mcp', { json: rpc('tools/call', call, 11) });++ assert.strictEqual(r.json().result.isError, true, `${call.name} should be a person error`);++ assert.match(r.json().result.content[0].text, /person/);++ }++ });++++ await check('bench.start_round creates a task and round for a person', async () => {++ const r = await t.get('/mcp', { as: alice, json: rpc('tools/call', { name: 'bench.start_round', arguments: { kind: 'design.page', prompt: 'A page for a robot.' } }, 12) });++ assert.strictEqual(r.json().result.isError, false, JSON.stringify(r.json().result));++ assert.match(r.json().result.structuredContent.id, /^brd_/);++ roundId = r.json().result.structuredContent.id;++ await t.tick();++ const round = await t.get(`/api/v1/rounds/${roundId}`, { as: alice });++ assert.strictEqual(round.json().round.status, 'finished');++ });++++ await check('bench.start_round with no prompt is a tool error', async () => {++ const r = await t.get('/mcp', { as: alice, json: rpc('tools/call', { name: 'bench.start_round', arguments: { kind: 'design.page' } }, 13) });++ assert.strictEqual(r.json().result.isError, true);++ assert.match(r.json().result.content[0].text, /prompt/);++ });++++ await check('bench.run_eval with no suite is a tool error', async () => {++ const r = await t.get('/mcp', { as: alice, json: rpc('tools/call', { name: 'bench.run_eval', arguments: { kind: 'vision.answer' } }, 14) });++ assert.strictEqual(r.json().result.isError, true);++ assert.match(r.json().result.content[0].text, /test_suite/);++ });++++ await check('bench.cast_judgement records a blind vote and reveals the entries', async () => {++ const pair = await t.get('/api/v1/judgements/next?kind=design.page', { as: bob });++ assert.strictEqual(pair.status, 200, pair.text);++ assert.ok(pair.json().left && pair.json().right);++ leftId = pair.json().left.id;++ rightId = pair.json().right.id;++ const r = await t.get('/mcp', { as: bob, json: rpc('tools/call', { name: 'bench.cast_judgement', arguments: { round: roundId, left: leftId, right: rightId, outcome: 'left' } }, 15) });++ assert.strictEqual(r.json().result.isError, false, JSON.stringify(r.json().result));++ assert.ok(r.json().result.structuredContent.revealed.left.model, 'revealed after the vote');++ });++++ await check('stop', async () => { await t.close(); });++++ done();++})();nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot({ env: { BENCH_EVALS_PER_DAY: '1', BENCH_ROUNDS_PER_DAY: '100', BENCH_ROUNDS_DAILY: '1000' } });++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const entrySvc = t.network.serviceToken({ sub: 'svc:room', audience: 'openvibe.bench', cap: ['bench.entry.create'] });++++ let evalId;++ let runId;++++ await check('a private eval is created and run under the free daily cap', async () => {++ const created = await t.get('/api/v1/evals', { as: alice, json: { kind_id: 'vision.answer', name: 'Held-out suite', test_suite: [{ prompt: 'What is 2 + 2?' }, { prompt: 'What is 3 + 3?' }] } });++ assert.strictEqual(created.status, 201, created.text);++ evalId = created.json().eval.id;++ const run = await t.get(`/api/v1/evals/${evalId}/run`, { as: alice, json: { offers: [{ provider: 'acme', model: 'alpha-1' }] } });++ assert.strictEqual(run.status, 202, run.text);++ runId = run.json().run.id;++ assert.strictEqual(run.json().run.status, 'queued');++ });++++ await check('the daily cap refuses a second run the same day', async () => {++ const run = await t.get(`/api/v1/evals/${evalId}/run`, { as: alice, json: { offers: [] } });++ assert.strictEqual(run.status, 429, run.text);++ });++++ await check('another owner cannot read the eval or its results', async () => {++ const r = await t.get(`/api/v1/evals/${evalId}`, { as: bob });++ assert.strictEqual(r.status, 404, r.text);++ const results = await t.get(`/api/v1/evals/${evalId}/results`, { as: bob });++ assert.strictEqual(results.status, 404, results.text);++ });++++ await check('the job runs the suite and results stay private', async () => {++ await t.tick();++ const r = await t.get(`/api/v1/evals/${evalId}/results`, { as: alice });++ assert.strictEqual(r.status, 200, r.text);++ const run = r.json().runs.find((x) => x.id === runId);++ assert.strictEqual(run.status, 'ready');++ assert.ok(Array.isArray(run.results));++ assert.strictEqual(r.json().eval.owner, alice.subject);++ });++++ await check('nothing private ever appears in the open dataset', async () => {++ const r = await t.get('/api/v1/datasets/vision.answer.jsonl');++ assert.strictEqual(r.status, 200, r.text);++ const lines = r.text.trim() ? r.text.trim().split('\n').map((l) => JSON.parse(l)) : [];++ assert.ok(lines.every((line) => !line.eval_id && !line.owner && !line.subject), 'dataset line leaked private fields');++ });++++ await check('a private eval never appears on a public board', async () => {++ const task = await t.get('/api/v1/kinds/vision.answer/tasks', { as: alice, json: { prompt: 'A public task.', public: true } });++ const rr = await t.get(`/api/v1/tasks/${task.json().task.id}/rounds`, { as: alice, json: {} });++ const roundId = rr.json().round.id;++ await t.get(`/api/v1/rounds/${roundId}/entries`, { bearer: entrySvc, json: { provider: 'acme', model: 'alpha-1', payload: { answer: 'public' } } });++ await t.get(`/api/v1/rounds/${roundId}/entries`, { bearer: entrySvc, json: { provider: 'beta', model: 'beta-2', payload: { answer: 'public' } } });++ for (const type of ['elo', 'pareto', 'cost']) {++ const board = await t.get(`/api/v1/boards/vision.answer?type=${type}`);++ assert.strictEqual(board.status, 200, `${type}: ${board.text}`);++ assert.ok(board.json().board.rows.every((row) => !String(row.subject).startsWith('bev_') && row.subject !== runId), 'private eval leaked into board');++ }++ });++++ await check('the open dataset has no voter id or person id anywhere', async () => {++ const r = await t.get('/api/v1/datasets/vision.answer.jsonl');++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.text.includes('usr_'), false, 'dataset contains a person id');++ for (const line of r.text.trim().split('\n').map((l) => JSON.parse(l))) {++ assert.strictEqual(line.subject, undefined, 'judgement leaked a voter id');++ assert.strictEqual(line.created_by, undefined, 'task or round leaked a person id');++ assert.strictEqual(line.owner, undefined, 'eval leaked an owner id');++ }++ });++++ await check('stop', async () => { await t.close(); });++++ done();++})();nnew file mode 100644@@@ -0,0 +1,107 @@++'use strict';++++const test = require('node:test');++const assert = require('node:assert/strict');++const { eloUpdate, bradleyTerry, voterWeights, wilson, pareto, costPerSolved, percentile } = require('../server/rating/lib');++++const game = (a, b, outcome, voter) => ({ a, b, outcome, voter });++const bySubject = fit => new Map(fit.map(row => [row.subject, row]));++++test('Elo produces known equal-rating win, tie, and both_bad values', () => {++ assert.deepEqual(eloUpdate(1000, 1000, 'a'), [1016, 984]);++ assert.deepEqual(eloUpdate(1000, 1000, 'tie'), [1000, 1000]);++ assert.deepEqual(eloUpdate(1000, 1000, 'both_bad'), [1000, 1000]);++ assert.deepEqual(eloUpdate(1000, 1000, 'b', 16), [992, 1008]);++ assert.ok(Math.abs(eloUpdate(1200, 1000, 'a')[0] - 1207.6880983472654) < 1e-9);++});++++test('Bradley-Terry recovers a planted three-subject order', () => {++ const games = [++ ...Array.from({ length: 18 }, () => game('A', 'B', 'a')),++ ...Array.from({ length: 2 }, () => game('A', 'B', 'b')),++ ...Array.from({ length: 15 }, () => game('B', 'C', 'a')),++ ...Array.from({ length: 5 }, () => game('B', 'C', 'b')),++ ...Array.from({ length: 19 }, () => game('A', 'C', 'a')),++ game('A', 'C', 'b'),++ ];++ const fit = bradleyTerry(games, { bootstrap: 0 });++ assert.deepEqual(fit.map(row => row.subject), ['A', 'B', 'C']);++ assert.ok(Math.abs(fit.reduce((sum, row) => sum + row.rating, 0) / 3 - 1000) < 1e-9);++ assert.deepEqual(fit.map(row => row.games), [40, 40, 40]);++ assert.ok(fit.every(row => !row.provisional));++});++++test('ties count as half a win for each side and both_bad is skipped', () => {++ const fit = bradleyTerry([game('A', 'B', 'tie'), game('A', 'B', 'both_bad')], { bootstrap: 0 });++ assert.equal(fit[0].rating, 1000);++ assert.equal(fit[1].rating, 1000);++ assert.deepEqual(fit.map(row => [row.games, row.wins, row.losses, row.ties]), [[1, 0, 0, 1], [1, 0, 0, 1]]);++ assert.ok(fit.every(row => row.provisional));++ assert.deepEqual(bradleyTerry([game('A', 'B', 'both_bad')]), []);++});++++test('bootstrap intervals are deterministic for a seed', () => {++ const games = Array.from({ length: 30 }, (_, i) => game('A', 'B', i % 3 ? 'a' : 'b'));++ const options = { bootstrap: 80, seed: 17 };++ assert.deepEqual(bradleyTerry(games, options), bradleyTerry(games, options));++ assert.notDeepEqual(bradleyTerry(games, options), bradleyTerry(games, { ...options, seed: 18 }));++});++++test('bootstrap intervals narrow with more games from the same mix', () => {++ const makeGames = n => Array.from({ length: n }, (_, i) => game('A', 'B', i % 5 < 3 ? 'a' : 'b'));++ const short = bradleyTerry(makeGames(20), { bootstrap: 250, seed: 7 });++ const long = bradleyTerry(makeGames(200), { bootstrap: 250, seed: 7 });++ assert.ok(long[0].ci_upper - long[0].ci_lower < short[0].ci_upper - short[0].ci_lower);++});++++test('weighted games affect the fit and dissenting voters are down-weighted after burn-in', () => {++ const games = [++ ...Array.from({ length: 20 }, () => game('A', 'B', 'a', 'agree')),++ ...Array.from({ length: 20 }, () => game('A', 'B', 'b', 'dissent')),++ game('A', 'B', 'b', 'new'),++ ];++ const fit = [{ subject: 'A', rating: 1100 }, { subject: 'B', rating: 900 }];++ const weights = voterWeights(games, fit);++ assert.equal(weights.get('agree'), 1);++ assert.equal(weights.get('dissent'), 0.25);++ assert.equal(weights.get('new'), 1);++ const weighted = bradleyTerry(games, { bootstrap: 0, weights });++ assert.equal(weighted[0].subject, 'A');++ assert.ok(bySubject(weighted).get('A').rating > 1000);++});++++test('one-sided and disconnected graphs yield finite ratings and intervals', () => {++ const fit = bradleyTerry([game('A', 'B', 'a'), game('C', 'D', 'a')], { bootstrap: 30 });++ assert.equal(fit.length, 4);++ assert.ok(fit.every(row => Number.isFinite(row.rating) && Number.isFinite(row.ci_lower) && Number.isFinite(row.ci_upper)));++});++++test('Wilson interval matches reference values and handles zero trials', () => {++ assert.deepEqual(wilson(0, 0), { p: 0, lower: 0, upper: 0 });++ const result = wilson(5, 10);++ assert.equal(result.p, 0.5);++ assert.ok(Math.abs(result.lower - 0.23658959361548731) < 1e-12);++ assert.ok(Math.abs(result.upper - 0.7634104063845126) < 1e-12);++});++++test('Pareto frontier retains tradeoffs and equal rows', () => {++ const rows = [{ id: 'cheap', cost: 1, score: 4 }, { id: 'best', cost: 3, score: 8 },++ { id: 'dominated', cost: 4, score: 3 }, { id: 'equal', cost: 1, score: 4 }];++ const result = pareto(rows, { x: 'cost', y: 'score' });++ assert.deepEqual(result.map(row => row.frontier), [true, true, false, true]);++ assert.equal(rows[0].frontier, undefined);++ assert.deepEqual(pareto([{ x: 1, y: 2 }, { x: 2, y: 3 }], { x: 'x', y: 'y', lowerX: false, higherY: false }).map(row => row.frontier), [true, true]);++});++++test('cost per solved sums costs and counts, with null for no solutions', () => {++ assert.deepEqual(costPerSolved([{ cost_usd: 2, solved: true }, { cost_usd: 4, solved: false }, { cost_usd: 3, solved: 2 }]),++ { cost_usd: 9, solved: 3, per_solved: 3 });++ assert.deepEqual(costPerSolved([]), { cost_usd: 0, solved: 0, per_solved: null });++});++++test('percentile interpolates and handles empty input', () => {++ assert.equal(percentile([1, 2, 3, 4], 50), 2.5);++ assert.equal(percentile([1, 2, 3, 4], 0.5), 2.5);++ assert.ok(Math.abs(percentile([1, 2, 3, 4], 95) - 3.85) < 1e-12);++ assert.equal(percentile([], 95), null);++});nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++++const assert = require('assert');++const rating = require('../server/rating');++const { check, done } = require('./helpers/boot');++++let failures = 0;++async function t(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`); }++}++++(async () => {++ await t('eloUpdate: a win moves rating up and the loser down', () => {++ const out = rating.eloUpdate(1000, 1000, 'left', 32);++ assert.ok(out.a > 1000);++ assert.ok(out.b < 1000);++ assert.strictEqual(Math.round((out.a - 1000) * 100) / 100, 16);++ });++++ await t('eloUpdate: a tie converges, both_bad leaves both unchanged', () => {++ const tie = rating.eloUpdate(1000, 1000, 'tie', 32);++ assert.strictEqual(tie.a, 1000);++ assert.strictEqual(tie.b, 1000);++ assert.deepStrictEqual(rating.eloUpdate(1200, 900, 'both_bad'), { a: 1200, b: 900 });++ });++++ await t('wilson: perfect and empty samples have the right bounds', () => {++ const full = rating.wilson(10, 10);++ assert.strictEqual(full.p, 1);++ assert.ok(full.lower > 0.5 && full.lower < 1);++ assert.ok(full.upper > full.lower);++ const empty = rating.wilson(0, 0);++ assert.deepStrictEqual(empty, { p: 0, lower: 0, upper: 0 });++ });++++ await t('pareto: the cheap-and-good frontier is marked', () => {++ const rows = [++ { name: 'best', x: 0.9, y: 0.1 },++ { name: 'dominated', x: 0.5, y: 0.5 },++ { name: 'cheap-low', x: 0.4, y: 0.05 },++ ];++ const out = rating.pareto(rows, { x: 'x', y: 'y' });++ assert.strictEqual(out.find((r) => r.name === 'best').frontier, true);++ assert.strictEqual(out.find((r) => r.name === 'dominated').frontier, false);++ assert.strictEqual(out.find((r) => r.name === 'cheap-low').frontier, true);++ });++++ await t('costPerSolved: sums solved spend only', () => {++ const entries = [++ { cost_usd: 0.1, solved: true },++ { cost_usd: 0.2, solved: true },++ { cost_usd: 0.3, solved: false },++ ];++ assert.strictEqual(rating.costPerSolved(entries), 0.15);++ assert.strictEqual(rating.costPerSolved([{ cost_usd: 0.5, solved: false }]), null);++ });++++ await t('bradleyTerry: deterministic, sorted, with games and bootstrap intervals', () => {++ const games = [++ { a: 'm1', b: 'm2', outcome: 'a' },++ { a: 'm1', b: 'm2', outcome: 'a' },++ { a: 'm1', b: 'm2', outcome: 'b' },++ { a: 'm2', b: 'm3', outcome: 'a' },++ ];++ const rows = rating.bradleyTerry(games, { bootstrap: 200, seed: 7 });++ assert.strictEqual(rows.length, 3);++ assert.ok(rows[0].rating > rows[1].rating);++ assert.strictEqual(rows.find((r) => r.subject === 'm1').games, 3);++ assert.ok(rows.every((r) => Number.isFinite(r.ci_lower) && Number.isFinite(r.ci_upper)));++ const again = rating.bradleyTerry(games, { bootstrap: 200, seed: 7 });++ assert.deepStrictEqual(rows, again);++ });++++ if (failures) { process.stdout.write(`\n${failures} failed\n`); process.exit(1); }++ process.stdout.write('\nall passed\n');++ process.exit(0);++})();nnew file mode 100644@@@ -0,0 +1,15 @@++#!/usr/bin/env node++/**++ * Runs every test/*.test.js in its own process and fails if any fails. They use temp PGlite databases, an++ * in-process mock of OpenVibe.Network and a fake AI; none needs the network or a paid API.++ */++'use strict';++const { run } = require('openvibe-shared/test-runner');++++run({ dir: __dirname, timeoutMs: 90000, pad: 34, parallel: 1 }).then((r) => {++ if (process.env.VERBOSE) {++ for (const t of r.results) console.log(`\n── ${t.file} ──\n${t.output.trimEnd()}`);++ console.log(`\n${r.summary}`);++ }++ process.exit(r.exitCode);++}, (err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,41 @@++'use strict';++++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++++ await check('a cookie write from another origin is refused', async () => {++ const r = await t.get('/api/v1/kinds/design.page/tasks', {++ as: alice,++ json: { prompt: 'A page for a robot.', public: true },++ origin: 'https://evil.example',++ });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json().code, 'bench.cross_site');++ });++++ await check('the same cookie write is allowed from this origin', async () => {++ const r = await t.get('/api/v1/kinds/design.page/tasks', {++ as: alice,++ json: { prompt: 'A page for a robot.', public: true },++ origin: t.config.baseUrl,++ });++ assert.strictEqual(r.status, 201, r.text);++ });++++ await check('a Bearer write is unaffected by the Origin header', async () => {++ const r = await t.get('/api/v1/kinds/design.page/tasks', {++ bearer: t.network.userToken(alice),++ json: { prompt: 'A page for a robot.', public: true },++ origin: 'https://evil.example',++ });++ assert.strictEqual(r.status, 201, r.text);++ });++++ await check('stop', async () => { await t.close(); });++++ done();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+# OpenVibe.Bench — production: /etc/openvibe/bench.env. Only variable NAMES appear in code and docs.
+NODE_ENV=production
+PORT=5230
+HOST=127.0.0.1
+BASE_URL=https://bench.openvibe.services
+TRUST_PROXY=2
+
+# PostgreSQL (ADR-035). DATABASE_URL serves, DATABASE_DIRECT_URL migrates (owner role).
+DATABASE_URL=
+DATABASE_DIRECT_URL=
+# Development fallback (no DATABASE_URL): embedded PGlite.
+BENCH_PGLITE_DIR=
+
+# OpenVibe.Network SSO/JWKS.
+OV_NETWORK_URL=https://openvibe.network
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+OV_NETWORK_ISSUER=https://openvibe.network
+OV_OAUTH_CLIENT_ID=bench
+OV_OAUTH_CLIENT_SECRET=
+OV_OAUTH_REDIRECT_URI=https://bench.openvibe.services/auth/callback
+OV_SESSION_AUDIENCE=openvibe.network
+BENCH_AUDIENCE=openvibe.bench
+COOKIE_SECURE=true
+
+# OpenVibe.AI (client credentials, audience openvibe.ai, scope ai.run.create; Network grants ai.generate ns).
+OV_AI_INTERNAL_URL=http://127.0.0.1:4700
+BENCH_AI_AUDIENCE=openvibe.ai
+BENCH_AI_SCOPE=ai.run.create
+BENCH_MODELS=
+
+# Bench budgets.
+BENCH_ROUNDS_PER_DAY=3
+BENCH_ROUNDS_DAILY=20
+BENCH_EVALS_PER_DAY=20
+
+# OpenVibe.Events outbox and account export/deletion.
+BENCH_EVENTS_URL=
+BENCH_EVENTS_SECRET=
+BENCH_EVENTS_SCOPE=events.event.publish
+
+# Optional Valkey for shared rate counters.
+VALKEY_URL=
+VALKEY_PREFIX=ov:bench:
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ - run: npm install
+ - run: OV_ALLOW_SUITE=1 npm test
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+node_modules/
+data/
+dist/
+.env
+.env.*
+!.env.example
+.npm-cache/
+*.log
+*.pem
+*.key
+!server/data/
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+# Changelog
+
+## 0.1.0 — 2026-10-28
+
+- OpenVibe.Bench v1: the shared AI benchmark and comparison engine (plan 2026-10-28 §4.12).
+- Fifteen kinds seeded from code; pinned, reproducible rounds; blind pairwise judgements with one vote per person per round and no vote on your own round.
+- Elo, pareto and cost boards; open dataset per kind without voter ids; private evals (free daily cap, no charging in v1).
+- MCP endpoint (`POST /mcp`) and events outbox (`bench.round.finished`, `bench.judgement.recorded`, `bench.rating.changed`).
new file mode 100644
new file mode 100644
@@ -0,0 +1,661 @@
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+# OpenVibe.Bench
+
+The shared AI benchmark and comparison engine for every OpenVibe room (plan 2026-10-28 §4.12).
+
+Bench is the comparative-evaluation authority. AI stays the run authority, Report the analytics authority, Route the learner, and each room keeps its own pages. Bench owns kinds, tasks, pinned rounds, entries, judgements, ratings, boards, the open dataset and private evals.
+
+- Service id: `bench`
+- Port: `5230`
+- Public origin: `https://bench.openvibe.services`
+- Database: `ov_bench`
+- Cookies: `bench_at`, `bench_rt`, `bench_oauth`
+
+## Run
+
+```
+npm install
+npm start
+```
+
+Without `DATABASE_URL` the service runs an embedded PGlite database in `data/pglite` (development only). Production uses PostgreSQL with `DATABASE_DIRECT_URL` for migrations.
+
+## Test
+
+```
+OV_ALLOW_SUITE=1 npm test
+```
+
+Tests use a temporary PGlite database, an in-process mock Network and a fake AI; none needs the network or a paid API.
+
+## API
+
+JSON under `/api/v1`; errors are `application/problem+json`. See `/docs` for the full list. The MCP endpoint is `POST /mcp`.
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+# 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.meme, openvibe.today, openvibe.tools,
+openvibe.media, openvibe.community, openvibe.games and the other openvibe.* domains) and every repository under
+github.com/OpenVibers. Machine-readable contact details are at `/.well-known/security.txt` on each site.
+
+## What OpenVibe.Meme holds
+
+- Public entries: the title, kind, AI-written summary, status, times and revision. An entry is the network's record,
+ not a person's row, so it is not exported or erased with an account.
+- Public citations: a verbatim quote from public speech, its sha256, the canonical public URL and the source ids
+ (chat message id, VOD/clip id, Rundown segment id). No private VOD bytes, thumbnails or metadata are kept.
+- A person's weekly votes, saved caption lines and reports. These are exported and erased with an account.
+- Network session tokens only in httpOnly cookies (`meme_at`, `meme_rt`); no token is stored in the database,
+ rendered into a page or logged.
+
+VOD 5709 and clip 483 are a fixed deny list: no title, thumbnail or quote from either is ever stored, and a
+citation naming them is dropped before it is written. A citation URL is re-read daily; a 404 marks it gone.
+
+A caption is plain text lines on a template; v1 never accepts, renders or stores an image uploaded by a person.
+The `/internal/events` account route is loopback only and refuses a proxied request; nginx answers 404 for
+`/internal` and `/metrics`.
+
+## Supported versions
+
+Only the current `main` branch, which is what runs in production, receives fixes.
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+{
+ "service": "bench",
+ "name": "OpenVibe.Bench",
+ "version": "0.1.0",
+ "updated": "2026-10-28",
+ "status": "v1",
+ "summary": "The shared AI benchmark and comparison engine for every room: kinds, pinned rounds, blind judgements, elo/pareto/cost boards, an open dataset per kind, private evals and an MCP endpoint."
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+## ═══════════════════════════════════════════════════════════════
+## bench.openvibe.services — Nginx Configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/bench.openvibe.services.conf — keep them in sync, and reload
+## nginx after changing it. It uses the *.openvibe.services certificate the host already holds
+## (/etc/letsencrypt/live/openvibe.services/), the same wildcard AI uses.
+##
+## The Node service (port 5230, systemd unit openvibe-bench) serves:
+## / the product home for a browser
+## /docs the Bench API docs (server-rendered, no sign-in)
+## /shared/* the home's OpenVibe Frame files; /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt
+## /api/health, /api/ready
+## /auth/* Network sign-in (redirect URI https://bench.openvibe.services/auth/callback, OAuth
+## client `bench`)
+## /mcp the Bench MCP endpoint (POST)
+## /api/v1/* the service API: service-to-service with first-party service tokens. Services on this
+## host call 127.0.0.1:5230 directly, so the public vhost refuses it.
+## /metrics Prometheus text, answered only to direct loopback callers (never proxied)
+## ═══════════════════════════════════════════════════════════════
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name bench.openvibe.services;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://bench.openvibe.services$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name bench.openvibe.services;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.services/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.services/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/bench.openvibe.services.access.log;
+ error_log /var/log/nginx/bench.openvibe.services.error.log;
+
+ # Task prompts, entry payloads and MCP bodies are the only request bodies Bench accepts.
+ client_max_body_size 64k;
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+ location = /api/health { proxy_pass http://127.0.0.1:5230; 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; }
+ location = /api/ready { proxy_pass http://127.0.0.1:5230; 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; }
+
+ # Metrics are for host-local scrapers only (http://127.0.0.1:5230/metrics).
+ location ~* ^/metrics(/|$) { return 404; }
+
+ # Service API: host-local only (see header).
+ location /api/ { return 403; }
+
+ # Network sign-in and the account-data loopback are handled by the app; the app refuses the loopback path.
+ location /auth/ {
+ proxy_pass http://127.0.0.1:5230;
+ 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;
+ }
+
+ location = /mcp {
+ limit_except POST { deny all; }
+ proxy_pass http://127.0.0.1:5230;
+ 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;
+ }
+
+ location = /robots.txt { proxy_pass http://127.0.0.1:5230; 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; }
+ location = /sitemap.xml { proxy_pass http://127.0.0.1:5230; 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; }
+ location = /llms.txt { proxy_pass http://127.0.0.1:5230; 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; }
+ location = /llms-full.txt { proxy_pass http://127.0.0.1:5230; 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; }
+ location = /release.json { proxy_pass http://127.0.0.1:5230; 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; }
+ location /shared/ { proxy_pass http://127.0.0.1:5230; 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; }
+
+ location = / {
+ proxy_pass http://127.0.0.1:5230;
+ 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;
+ }
+
+ # Everything else: the app answers GET/HEAD (its /docs, /favicon.ico, and its own 404 page); other methods
+ # are refused here.
+ location / {
+ limit_except GET HEAD { deny all; }
+ proxy_pass http://127.0.0.1:5230;
+ 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;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+[Unit]
+Description=OpenVibe.Bench — the shared AI benchmark engine
+After=network.target
+
+[Service]
+Type=simple
+User=openvibe
+EnvironmentFile=/etc/openvibe/bench.env
+ExecStart=/usr/bin/node /opt/openvibe/bench/server/index.js
+Restart=on-failure
+RestartSec=5
+TimeoutStopSec=15
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,192 @@
@@ -0,0 +1,192 @@
+-- phase: expand
+-- OpenVibe.Bench (plan 2026-10-28 §4.12): the comparative-evaluation authority. Kinds, tasks, pinned rounds,
+-- entries, judgements, ratings, private evals and the events outbox. Nothing written here is a credential; the
+-- public dataset is anonymous by design and a judgement never carries a voter id into an export.
+
+CREATE TABLE bench_kinds (
+ id text COLLATE "C" PRIMARY KEY, -- e.g. design.page
+ room text COLLATE "C" NOT NULL,
+ entry text COLLATE "C" NOT NULL,
+ judge text COLLATE "C" NOT NULL, -- human_pairwise | rubric | automatic_grader | test_suite | game_outcome
+ name text NOT NULL,
+ schema jsonb NOT NULL DEFAULT '{}'::jsonb,
+ metrics jsonb NOT NULL DEFAULT '[]'::jsonb,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT bench_kinds_id CHECK (id ~ '^[a-z0-9]+(\.[a-z0-9]+)*$'),
+ CONSTRAINT bench_kinds_room CHECK (length(room) BETWEEN 1 AND 40),
+ CONSTRAINT bench_kinds_judge CHECK (judge IN ('human_pairwise', 'rubric', 'automatic_grader', 'test_suite', 'game_outcome'))
+);
+
+CREATE TABLE bench_tasks (
+ id text COLLATE "C" PRIMARY KEY, -- btk_ + ULID
+ kind_id text COLLATE "C" NOT NULL REFERENCES bench_kinds (id),
+ prompt text NOT NULL,
+ task_class text COLLATE "C", -- the class Route learns from
+ public boolean NOT NULL DEFAULT false,
+ created_by text COLLATE "C" NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT bench_tasks_id CHECK (id ~ '^btk_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT bench_tasks_prompt CHECK (length(prompt) BETWEEN 1 AND 4000),
+ CONSTRAINT bench_tasks_public CHECK (public IN (true, false))
+);
+CREATE INDEX bench_tasks_by_kind ON bench_tasks (kind_id, created_at DESC);
+
+CREATE TABLE bench_rounds (
+ id text COLLATE "C" PRIMARY KEY, -- brd_ + ULID
+ task_id text COLLATE "C" NOT NULL REFERENCES bench_tasks (id),
+ kind_id text COLLATE "C" NOT NULL REFERENCES bench_kinds (id),
+ offers jsonb NOT NULL DEFAULT '[]'::jsonb, -- pinned { provider, model, version?, params? }
+ status text COLLATE "C" NOT NULL DEFAULT 'queued', -- queued | running | ready | finished | failed
+ seed integer NOT NULL DEFAULT 0,
+ params jsonb NOT NULL DEFAULT '{}'::jsonb,
+ pool_fallback boolean NOT NULL DEFAULT false,
+ receipt text COLLATE "C", -- sha256 of the finished round's reproducible parts
+ created_by text COLLATE "C" NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ finished_at timestamptz,
+ CONSTRAINT bench_rounds_id CHECK (id ~ '^brd_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT bench_rounds_status CHECK (status IN ('queued', 'running', 'ready', 'finished', 'failed')),
+ CONSTRAINT bench_rounds_receipt CHECK (receipt IS NULL OR receipt ~ '^[0-9a-f]{64}$')
+);
+CREATE INDEX bench_rounds_status_created ON bench_rounds (status, created_at);
+CREATE INDEX bench_rounds_by_kind ON bench_rounds (kind_id, created_at DESC);
+CREATE INDEX bench_rounds_by_creator ON bench_rounds (created_by, created_at DESC);
+
+CREATE TABLE bench_entries (
+ id text COLLATE "C" PRIMARY KEY, -- bte_ + ULID
+ round_id text COLLATE "C" NOT NULL REFERENCES bench_rounds (id) ON DELETE CASCADE,
+ provider text COLLATE "C" NOT NULL,
+ model text COLLATE "C" NOT NULL,
+ route jsonb NOT NULL DEFAULT '{}'::jsonb, -- the route that produced the entry
+ payload jsonb NOT NULL DEFAULT '{}'::jsonb, -- the output (html, patch, transcript, game result, ...)
+ cost_usd numeric(12, 6) NOT NULL DEFAULT 0,
+ ms integer NOT NULL DEFAULT 0,
+ tokens integer NOT NULL DEFAULT 0,
+ error text,
+ ui_check jsonb,
+ run_id text,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT bench_entries_id CHECK (id ~ '^bte_[0-9A-HJKMNP-TV-Z]{26}$')
+);
+CREATE INDEX bench_entries_by_round ON bench_entries (round_id);
+CREATE INDEX bench_entries_by_model ON bench_entries (model);
+
+CREATE TABLE bench_judgements (
+ id text COLLATE "C" PRIMARY KEY, -- bju_ + ULID
+ round_id text COLLATE "C" NOT NULL REFERENCES bench_rounds (id) ON DELETE CASCADE,
+ kind_id text COLLATE "C" NOT NULL REFERENCES bench_kinds (id),
+ judge text COLLATE "C" NOT NULL, -- the kind's judge
+ judge_name text, -- named automatic grader / test suite
+ judge_version text,
+ method text COLLATE "C" NOT NULL, -- the kind's judge
+ pair jsonb, -- { left, right } for a pairwise judgement
+ outcome text COLLATE "C", -- left | right | tie | both_bad | pass | fail | draw
+ score numeric(12, 6),
+ subject text COLLATE "C", -- the person (usr_…) or service; never in the open dataset
+ at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT bench_judgements_id CHECK (id ~ '^bju_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT bench_judgements_method CHECK (method IN ('human_pairwise', 'rubric', 'automatic_grader', 'test_suite', 'game_outcome')),
+ CONSTRAINT bench_judgements_outcome CHECK (outcome IS NULL OR outcome IN ('left', 'right', 'tie', 'both_bad', 'pass', 'fail', 'draw'))
+);
+CREATE INDEX bench_judgements_by_round ON bench_judgements (round_id, at);
+CREATE INDEX bench_judgements_by_kind ON bench_judgements (kind_id, at);
+CREATE INDEX bench_judgements_by_subject ON bench_judgements (subject, at);
+
+CREATE TABLE bench_ratings (
+ kind_id text COLLATE "C" NOT NULL,
+ category text COLLATE "C" NOT NULL DEFAULT 'all',
+ subject text COLLATE "C" NOT NULL, -- the reported model name
+ elo numeric(8, 2) NOT NULL DEFAULT 1000,
+ games integer NOT NULL DEFAULT 0,
+ wins integer NOT NULL DEFAULT 0,
+ losses integer NOT NULL DEFAULT 0,
+ ties integer NOT NULL DEFAULT 0,
+ win_rate numeric(8, 4) NOT NULL DEFAULT 0,
+ ci_lower numeric(8, 2),
+ ci_upper numeric(8, 2),
+ median_cost_usd numeric(12, 6),
+ median_ms numeric(12, 1),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (kind_id, category, subject),
+ CONSTRAINT bench_ratings_counters CHECK (games >= 0 AND wins >= 0 AND losses >= 0 AND ties >= 0)
+);
+CREATE INDEX bench_ratings_order ON bench_ratings (kind_id, category, elo DESC, games DESC);
+
+CREATE TABLE bench_evals (
+ id text COLLATE "C" PRIMARY KEY, -- bev_ + ULID
+ owner text COLLATE "C" NOT NULL, -- usr_… or prj_…; private by construction
+ kind_id text COLLATE "C" NOT NULL REFERENCES bench_kinds (id),
+ name text NOT NULL,
+ test_suite jsonb NOT NULL DEFAULT '[]'::jsonb, -- the owner's held-out tasks
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT bench_evals_id CHECK (id ~ '^bev_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT bench_evals_name CHECK (length(name) BETWEEN 1 AND 160)
+);
+CREATE INDEX bench_evals_by_owner ON bench_evals (owner, created_at DESC);
+
+CREATE TABLE bench_eval_runs (
+ id text COLLATE "C" PRIMARY KEY, -- ber_ + ULID
+ eval_id text COLLATE "C" NOT NULL REFERENCES bench_evals (id) ON DELETE CASCADE,
+ offers jsonb NOT NULL DEFAULT '[]'::jsonb,
+ status text COLLATE "C" NOT NULL DEFAULT 'queued', -- queued | running | ready | failed
+ results jsonb, -- private results, never in the public dataset
+ created_at timestamptz NOT NULL DEFAULT now(),
+ finished_at timestamptz,
+ CONSTRAINT bench_eval_runs_id CHECK (id ~ '^ber_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT bench_eval_runs_status CHECK (status IN ('queued', 'running', 'ready', 'failed'))
+);
+CREATE INDEX bench_eval_runs_by_eval ON bench_eval_runs (eval_id, created_at DESC);
+
+-- The events outbox (Today's shape). A round.finished / judgement.recorded / rating.changed row is written in
+-- the same transaction as the change; the publisher claims and sends it to OpenVibe.Events.
+CREATE TABLE bench_outbox (
+ event_id text COLLATE "C" PRIMARY KEY,
+ topic text COLLATE "C" NOT NULL,
+ payload jsonb NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ published_at timestamptz,
+ attempts integer NOT NULL DEFAULT 0,
+ last_error text,
+ next_attempt_at timestamptz NOT NULL DEFAULT now(),
+ claimed_at timestamptz,
+ claimed_by text
+);
+CREATE INDEX bench_outbox_pending ON bench_outbox (next_attempt_at) WHERE published_at IS NULL;
+
+-- openvibe-sdk/account-data idempotency (ADR-033).
+CREATE TABLE account_data_events (
+ id text PRIMARY KEY,
+ kind text NOT NULL,
+ subject text NOT NULL,
+ outcome jsonb,
+ applied_at timestamptz NOT NULL DEFAULT now(),
+ sent_at timestamptz
+);
+
+-- Request telemetry (openvibe-sdk/telemetry).
+CREATE TABLE telemetry_samples (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ at timestamptz NOT NULL,
+ service text NOT NULL,
+ project text,
+ subject text,
+ resource text,
+ provider text,
+ node text,
+ cell text,
+ region text,
+ operation text NOT NULL,
+ latency_ms double precision,
+ queue_delay_ms double precision,
+ ttfb_ms double precision,
+ throughput_per_second double precision,
+ bytes bigint,
+ status text,
+ cache_status text,
+ cost_estimate double precision,
+ route_epoch bigint,
+ trace_id text,
+ extra jsonb NOT NULL DEFAULT '{}'::jsonb
+);
+CREATE INDEX telemetry_samples_at_idx ON telemetry_samples (at);
+CREATE INDEX telemetry_samples_service_idx ON telemetry_samples (service, operation, at);
new file mode 100644
new file mode 100644
@@ -0,0 +1,1407 @@
@@ -0,0 +1,1407 @@
+{
+ "name": "openvibe-bench",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-bench",
+ "version": "0.1.0",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "helmet": "^8.1.0",
+ "iovalkey": "^0.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
+ "pg": "^8.23.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.13",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",
+ "integrity": "sha512-4rZuxK1k07w6sSpJzjMri0B9+HuXuC7V9pxVB51pR0T9KPqXg6TD0mdaEwBA9rkcL4jdht4tGDf/wYzAiGhT7g==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.9",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz",
+ "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.16.0",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-parser": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.3",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
+ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.13",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.16.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "7.5.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
+ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.9",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
+ "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/helmet": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
+ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/EvanHahn"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/iovalkey/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
+ "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/openvibe-contracts": {
+ "version": "0.238.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0",
+ "integrity": "sha512-BQOsqor9ew75s2ElWK6g6pWk0gr5nbwzgfzMKJPBPIjnDnIkUQfNmsYw8AeexW5qeotCmfTu4lSPdqy6ZQaq5g==",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1"
+ },
+ "bin": {
+ "openvibe-contracts-check": "bin/check-service.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/openvibe-sdk": {
+ "version": "0.70.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
+ "integrity": "sha512-iMNxlnRm0fmyj60nHiC/lKAaG3JAqiJgu42lShnkby4yF4dQJRG3G0trrqOoULZKBalrYHb/gjauRwNQYN/tog==",
+ "license": "MIT",
+ "bin": {
+ "openvibe-download": "bin/openvibe-download.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0"
+ },
+ "peerDependenciesMeta": {
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "iovalkey": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/openvibe-shared": {
+ "version": "4.31.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
+ "integrity": "sha512-Zk1t1RGsiszXJEyW3x/cVCYC6U1qyLXID43l/Ehv2Py5gGglz5CfgYlPcDGI7n3QACsCWoOIGZuMBvlyfxyXEA==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ },
+ "bin": {
+ "openvibe-build-assets": "scripts/build-assets.js"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/pg": {
+ "version": "8.23.1",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
+ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.1",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.1",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.1"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
+ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
+ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
+ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.16.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",
+ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+{
+ "name": "openvibe-bench",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe.Bench — the shared AI benchmark and comparison engine for every room.",
+ "main": "server/index.js",
+ "scripts": {
+ "start": "node server/index.js",
+ "dev": "NODE_ENV=development node server/index.js",
+ "test": "node test/run.js",
+ "test:pg": "BENCH_TEST_STORE=pg node test/run.js"
+ },
+ "keywords": [
+ "openvibe"
+ ],
+ "author": "OpenVibers",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "helmet": "^8.1.0",
+ "iovalkey": "^0.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.238.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
+ "pg": "^8.23.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+/* OpenVibe.Bench: the developer service's own pieces. Colours come from the theme (--bg-*, --text-*, --border) and the room's accent
+ (.ov-room: --room-accent, --room-strong, --room-fill, --room-on), so every theme, light or dark, wears it. Phone
+ first; nothing scrolls sideways. Boards are JSON here; Report draws them. */
+*, *::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; }
+.bm { display: block; }
+.bm a { color: var(--room-strong); }
+.bm .ovk-i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
+.bm-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }
+.bm-narrow { max-width: 620px; }
+.bm-page-pad { padding-top: clamp(28px, 6vw, 64px); }
+.bm h2 { margin: 0; font-size: 1.2rem; font-weight: 800; letter-spacing: -.01em; }
+.bm-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }
+.bm-page-h { padding-top: clamp(20px, 4vw, 40px); margin-bottom: 22px; }
+.bm-page-h h1, .bm-entry-h h1 { margin: 0; font-size: clamp(1.9rem, 5vw, 3rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.06; overflow-wrap: anywhere; }
+.bm-lede, .bm-summary { margin: 10px 0 0; max-width: 700px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
+.bm-fine, .bm-muted { color: var(--text-muted, #7386a3); font-size: .88rem; line-height: 1.55; }
+.bm-section { margin-top: clamp(28px, 4vw, 44px); }
+.bm-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
+.bm-count { color: var(--text-muted, #7386a3); font-size: .86rem; }
+.bm-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; }
+.bm-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
+.bm-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--room-fill); color: var(--room-on) !important; font: inherit; font-weight: 700; font-size: .95rem; text-decoration: none; cursor: pointer; }
+.bm-btn-quiet { background: transparent; color: var(--text-primary, #e6edf7) !important; border: 1px solid var(--border, #1f2d47); }
+.bm-btn-small { min-height: 34px; padding: 0 14px; font-size: .86rem; }
+.bm-btn.is-mine { background: color-mix(in srgb, var(--room-accent) 22%, transparent); color: var(--room-strong) !important; }
+.bm-btn:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }
+.bm-note { margin: 14px 0; padding: 11px 14px; border-radius: 14px; font-weight: 600; background: color-mix(in srgb, var(--room-accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--room-accent) 30%, transparent); }
+.bm-note.is-bad { background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); border-color: color-mix(in srgb, var(--error, #ef4444) 35%, transparent); }
+.bm-empty { display: grid; justify-items: start; gap: 10px; padding: 22px; border-radius: 18px; border: 1px dashed var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); }
+.bm-empty p { margin: 0; line-height: 1.5; }
+.bm-empty h1 { margin: 0; font-size: 1.6rem; color: var(--text-primary, #e6edf7); }
+
+
+.bm-card { padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.bm-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 20px 0 0; }
+.bm-facts > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }
+.bm-facts dt { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.bm-facts dd { margin: 4px 0 0; font-weight: 700; overflow-wrap: anywhere; }
+.bm-eps { display: grid; gap: 10px; margin: 12px 0 0; }
+.bm-eps > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.bm-eps dt code { font-weight: 800; color: var(--room-strong); overflow-wrap: anywhere; }
+.bm-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
+
+/* The hero stands alone (no room picture): a wide headline, then the four promises as a row of cards */
+.bm-hero { max-width: 880px; padding: clamp(1.5rem, 5vw, 4.5rem) 0 1rem; }
+.bm-hero h1 { margin: 0 0 14px; font-size: clamp(2.1rem, 5.4vw, 4.2rem); line-height: 1.02; letter-spacing: -.035em; }
+.bm-why { list-style: none; margin: 26px 0 8px; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr)); }
+.bm-why li { display: grid; gap: 6px; padding: 16px 18px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-card, #0f1729); line-height: 1.5; }
+.bm-why b { font-size: 1rem; color: var(--room-strong); }
+.bm-why span { color: var(--text-secondary, #96a7c2); font-size: .92rem; }
+.bm-count { min-width: 28px; padding: 2px 9px; border-radius: 999px; background: color-mix(in srgb, var(--room-accent) 16%, transparent); color: var(--room-strong); font-weight: 800; font-size: .8rem; text-align: center; font-variant-numeric: tabular-nums; }
+
+/* Kinds: a grid of monospace ids with their room and judge */
+.bm-kinds { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 250px), 1fr)); }
+.bm-kind { display: grid; gap: 4px; padding: 12px 14px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #0f1729); min-width: 0; }
+.bm-kind:hover { border-color: var(--room-accent); }
+.bm-kind-link { font: 700 .95rem/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--room-strong); text-decoration: none; overflow-wrap: anywhere; }
+.bm-kind-link:hover { text-decoration: underline; }
+.bm-kind-link:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; border-radius: 4px; }
+.bm-kind-meta { font-size: .82rem; color: var(--text-muted, #7386a3); }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+/* OpenVibe.Bench pages work without JavaScript; the main session designs this room later. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,174 @@
@@ -0,0 +1,174 @@
+'use strict';
+
+/**
+ * OpenVibe.Bench — Express app factory; server/index.js listens, tests build their own instance with a temporary
+ * database and a mock Network/AI.
+ *
+ * /, /docs the pages (http/pages.js)
+ * /api/v1/* the API (http/api.js)
+ * /mcp the MCP endpoint (mcp.js)
+ * /auth/* Network SSO with PKCE (auth/sso.js)
+ * /internal/events ADR-033 account export/deletion
+ * /api/health, /api/ready, /release.json, /metrics machine endpoints
+ */
+const path = require('path');
+const express = require('express');
+const helmet = require('helmet');
+const cookieParser = require('cookie-parser');
+const rateLimit = require('express-rate-limit');
+const contracts = require('openvibe-contracts');
+const cache = require('openvibe-shared/cache-policy');
+
+const configLib = require('./config');
+const { openStore } = require('./db');
+const { createKeyStore } = require('./auth/keys');
+const { createSso } = require('./auth/sso');
+const { createPrincipal } = require('./http/principal');
+const { createApi } = require('./http/api');
+const { createPageRoutes } = require('./http/pages');
+const { createServiceReadiness } = require('./observability');
+const { assetVersion, send } = require('./render/layout');
+const { raw } = require('./render/html');
+
+const accountDataLib = require('./identity/account-data');
+const { createStore } = require('./store');
+const { createBenchAi } = require('./bench/ai');
+const { createBenchJobs } = require('./bench/jobs');
+const { createPublisher } = require('./events/publisher');
+const { mcpRouter } = require('./mcp');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
+
+const PUBLIC_DIR = path.join(__dirname, '..', 'public');
+const VERSION = require('../package.json').version;
+const { createHttpTelemetry } = require('openvibe-sdk/service');
+const { createPgTelemetrySink } = require('openvibe-sdk/telemetry');
+
+/**
+ * opts: config, store, bench, benchAi, accountSend, telemetrySink, fetchImpl, log, now, random.
+ */
+async function createApp(opts = {}) {
+ const config = opts.config || configLib.load();
+ const log = opts.log || console;
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
+ const s = opts.store || await openStore(config, { now: opts.now, log });
+
+ const keys = createKeyStore({ config, fetchImpl, log });
+ const sso = createSso({ config, keys, fetchImpl, now: opts.now || (() => Date.now()), log });
+ const principal = createPrincipal({ config, keys });
+
+ const bench = opts.bench || createStore(s, { config, log, random: opts.random });
+ const ai = opts.benchAi || createBenchAi({ config, fetchImpl, log });
+ const jobs = createBenchJobs({ s, bench, ai, config, log });
+ const publisher = createPublisher({ config, s, fetchImpl, log });
+
+ await bench.seedKinds();
+
+ const accountData = accountDataLib.create({ db: s.db, log });
+ const accountSend = opts.accountSend || (config.oauth.clientSecret
+ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
+ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Bench cannot answer account events'); });
+
+ const ctx = {
+ config, s, keys, sso, principal, bench, ai, jobs, publisher, accountData, accountSend, log,
+ random: opts.random || Math.random,
+ };
+
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', config.trustProxy);
+ const release = require('openvibe-shared/release').createRelease({ service: 'bench', root: path.join(__dirname, '..') });
+ require('./render/layout').setRelease(release.release);
+ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'bench', release: release.release });
+ app.locals.metrics = metrics.registry;
+ ctx.telemetry = createHttpTelemetry({ service: 'bench', sink: opts.telemetrySink || createPgTelemetrySink({ db: s.db, log }), skipPrefixes: ['/shared'], intervalMs: 60_000 });
+ app.use(ctx.telemetry.middleware());
+ app.locals.ctx = ctx;
+
+ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);
+ ctx.valkey = valkey;
+
+ app.use(contracts.http.middleware());
+ app.use(helmet({
+ contentSecurityPolicy: {
+ directives: {
+ defaultSrc: ["'self'"],
+ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
+ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
+ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
+ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
+ connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
+ frameSrc: ["'self'", 'https://openvibe.network'],
+ frameAncestors: ["'none'"],
+ objectSrc: ["'none'"],
+ baseUri: ["'self'"],
+ formAction: ["'self'", 'https://openvibe.network'],
+ },
+ },
+ frameguard: { action: 'deny' },
+ crossOriginEmbedderPolicy: false,
+ crossOriginResourcePolicy: { policy: 'same-site' },
+ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
+ }));
+ app.use(cookieParser());
+
+ // ── Machine endpoints ───────────────────────────────────
+ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-bench', name: 'OpenVibe.Bench', version: VERSION }));
+ release.mount(app, { registry: metrics.registry });
+ const readiness = createServiceReadiness({ s, config, release: release.release, valkey });
+ app.get('/api/ready', readiness.handler);
+
+ // ── Who is signed in ────────────────────────────────────
+ app.use(sso.middleware());
+
+ // ── Sign-in ─────────────────────────────────────────────
+ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
+ app.use('/auth', sso.routes());
+
+ // ── OpenVibe.Events → this service (loopback only) ──────
+ app.use('/internal/events', accountData.consumer({
+ secrets: config.events.secrets,
+ send: accountSend,
+ now: s.now,
+ onEvent: async (ev) => `ignored:${ev.event_type}`,
+ }));
+
+ // ── Static assets ───────────────────────────────────────
+ 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 and MCP ─────────────────────────────────────────
+ app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.BENCH_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
+ app.use('/api/v1', createApi(ctx));
+ app.use(mcpRouter(ctx));
+ app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
+
+ // ── Pages ───────────────────────────────────────────────
+ app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.BENCH_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
+ app.use(createPageRoutes(ctx));
+ app.use((req, res) => send(res, 404, {
+ viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',
+ body: raw(require('./views').notFound()),
+ }));
+
+ // eslint-disable-next-line no-unused-vars
+ app.use((err, req, res, _next) => {
+ log.error('[OpenVibe.Bench]', err && err.message ? err.message.slice(0, 300) : err);
+ if (res.headersSent) return;
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (err && err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });
+ if (err && err.type === 'entity.too.large') return req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov }) : res.status(413).type('text/plain').send('That request was too large.');
+ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
+ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
+ });
+
+ return { app, ctx };
+}
+
+module.exports = { createApp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
+'use strict';
+
+/**
+ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the internal URL and kept fresh
+ * by openvibe-sdk/auth's JWKS client. Tokens are verified offline against it.
+ */
+const sdk = require('openvibe-sdk/auth');
+
+const KEY_UNAVAILABLE = 'signing key not loaded yet';
+const DOES_NOT_VERIFY = 'token does not verify';
+
+function publicReason(err) {
+ const code = err && err.code;
+ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;
+ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;
+}
+
+function jwksUrl(config) {
+ return (config.networkInternalUrl || config.networkUrl) + '/api/.well-known/jwks';
+}
+
+function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {
+ const url = jwksUrl(config);
+ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });
+ let mirror = null;
+
+ async function refreshMirror() {
+ try {
+ const ks = await client.keys();
+ const first = ks && ks[0];
+ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;
+ } catch (err) { log.warn(`[OpenVibe.Bench] JWKS mirror refresh failed: ${err && err.message || err}`); mirror = null; }
+ }
+ async function ensure() { await refreshMirror(); }
+ function get() { return mirror ? mirror.pem : null; }
+ function loaded() { return Boolean(mirror); }
+
+ const asMs = (v) => (typeof v === 'function' ? v() : (typeof v === 'number' ? v : Date.now()));
+
+ async function verifyUser(token, { issuer, audience, now = Date.now() } = {}) {
+ try {
+ const claims = await sdk.verifyUserToken(token, { jwks: url, issuer, audience, now: asMs(now), log });
+ await refreshMirror();
+ return { ok: true, claims };
+ } catch (err) {
+ await refreshMirror();
+ const code = err && err.code;
+ log.warn(`[OpenVibe.Bench] user token rejected (${code || 'error'}):`, (err && err.message) || err);
+ return { ok: false, reason: publicReason(err), code, expired: code === 'token.expired' };
+ }
+ }
+
+ async function pemForToken(token) {
+ let kid = null;
+ try { kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { /* refused */ }
+ try {
+ const ks = await client.keysForKid(kid);
+ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];
+ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;
+ } catch { return null; }
+ }
+
+ return { ensure, get, loaded, status: client.status, verifyUser, pemForToken, client };
+}
+
+module.exports = { createKeyStore, jwksUrl };
new file mode 100644
new file mode 100644
@@ -0,0 +1,258 @@
@@ -0,0 +1,258 @@
+'use strict';
+
+/**
+ * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `bench`.
+ *
+ * GET /auth/login → Network /oauth/authorize
+ * GET /auth/callback → state check, server-side code exchange
+ * GET /auth/logout → clear cookies, revoke the refresh token (best effort)
+ * GET /auth/me → { user } for the shared navbar
+ *
+ * The person's Network access token lives in the httpOnly `bench_at` cookie; `bench_rt` is the refresh token and
+ * `bench_oauth` the short-lived PKCE flow. None are stored server-side, rendered or logged.
+ */
+const crypto = require('crypto');
+const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
+
+const ACCESS_COOKIE = 'bench_at';
+const REFRESH_COOKIE = 'bench_rt';
+const HINT_COOKIE = 'ov_sso_hint';
+const FLOW_COOKIE = 'bench_oauth';
+
+function pkcePair() {
+ const verifier = crypto.randomBytes(32).toString('base64url');
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
+ return { verifier, challenge };
+}
+
+function ownOrigins(config) {
+ const out = new Set();
+ for (const u of [config && config.baseUrl]) {
+ try { if (u) out.add(new URL(u).origin); } catch { /* not a URL */ }
+ }
+ return out;
+}
+
+function sanitizeNext(next, config) {
+ if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
+ if (typeof next === 'string' && /^https?:\/\//i.test(next)) {
+ let u = null;
+ try { u = new URL(next); } catch { return '/'; }
+ if (!ownOrigins(config).has(u.origin)) return '/';
+ next = `${u.pathname}${u.search}${u.hash}`;
+ }
+ if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';
+ return next;
+}
+
+function authorizeUrl(config, { state, challenge, silent = false }) {
+ const q = new URLSearchParams({
+ response_type: 'code',
+ client_id: config.oauth.clientId,
+ redirect_uri: config.oauth.redirectUri,
+ scope: config.oauth.scope,
+ state,
+ code_challenge: challenge,
+ code_challenge_method: 'S256',
+ });
+ if (silent) q.set('prompt', 'none');
+ return `${config.networkUrl}/oauth/authorize?${q.toString()}`;
+}
+
+function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
+ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });
+
+ function setSession(res, data) {
+ res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });
+ if (data.refresh_token) res.cookie(REFRESH_COOKIE, data.refresh_token, { ...cookieBase(), maxAge: 30 * 24 * 60 * 60 * 1000 });
+ res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
+ }
+ function clearSession(res) {
+ res.clearCookie(ACCESS_COOKIE, cookieBase());
+ res.clearCookie(REFRESH_COOKIE, cookieBase());
+ }
+
+ async function tokenGrant(fields) {
+ const body = new URLSearchParams({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...fields });
+ let res;
+ try {
+ res = await fetchImpl(`${config.networkInternalUrl}/oauth/token`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
+ body, signal: AbortSignal.timeout(10_000),
+ });
+ } catch (err) {
+ throw Object.assign(new Error('OpenVibe.Network did not answer'), { status: 502, error: err && err.name === 'TimeoutError' ? 'timeout' : 'unreachable' });
+ }
+ const data = await res.json().catch(() => null);
+ if (!res.ok || !data || !data.access_token) {
+ throw Object.assign(new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`),
+ { status: res.status, error: (data && data.error) || 'invalid_grant' });
+ }
+ return data;
+ }
+
+ async function verifySession(token) {
+ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });
+ const c = v.claims;
+ if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };
+ return v;
+ }
+
+ function viewerFromClaims(claims, token) {
+ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;
+ const role = typeof claims.role === 'string' ? claims.role : 'user';
+ return {
+ kind: 'user',
+ subject,
+ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
+ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (typeof claims.username === 'string' ? claims.username.slice(0, 64) : 'you'),
+ role,
+ avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,
+ profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,
+ token,
+ };
+ }
+
+ async function refresh(req, res) {
+ if (!req.sessionRefresh) req.sessionRefresh = await doRefresh(req, res);
+ return req.sessionRefresh;
+ }
+
+ async function doRefresh(req, res) {
+ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
+ if (!rt) return null;
+ try {
+ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });
+ setSession(res, data);
+ const v = await verifySession(data.access_token);
+ return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;
+ } catch (err) {
+ if (err.status && err.status < 500) clearSession(res);
+ return null;
+ }
+ }
+
+ function middleware() {
+ return async (req, res, next) => {
+ req.viewer = { kind: 'anonymous' };
+ const header = String(req.get('authorization') || '');
+ const bearerToken = /^Bearer\s+\S/i.test(header) ? header.replace(/^Bearer\s+/i, '').trim() : null;
+ if (bearerToken) {
+ await keys.ensure();
+ const v = await verifySession(bearerToken);
+ if (v.ok) req.viewer = viewerFromClaims(v.claims, bearerToken);
+ return next();
+ }
+ const token = req.cookies && req.cookies[ACCESS_COOKIE];
+ if (!token) return next();
+ await keys.ensure();
+ const v = await verifySession(token);
+ if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }
+ if (v.expired) {
+ const fresh = await refresh(req, res);
+ if (fresh) { req.viewer = fresh; return next(); }
+ }
+ if (keys.loaded()) clearSession(res);
+ next();
+ };
+ }
+
+ async function asViewer(req, res, fn) {
+ try {
+ return await fn(req.viewer.token);
+ } catch (err) {
+ if (!err || err.status !== 401) throw err;
+ const fresh = await refresh(req, res);
+ if (!fresh) throw err;
+ req.viewer = fresh;
+ return fn(fresh.token);
+ }
+ }
+
+ function routes() {
+ const r = express.Router();
+ const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });
+
+ r.get('/login', (req, res) => {
+ const silent = Boolean(req.query.silent) && req.query.silent !== '0';
+ const state = crypto.randomBytes(16).toString('hex');
+ const { verifier, challenge } = pkcePair();
+ res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next, config), silent }), flowOpts());
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ res.redirect(authorizeUrl(config, { state, challenge, silent }));
+ });
+
+ r.get('/callback', async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ let flow = null;
+ try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }
+ res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });
+ const next = flow ? sanitizeNext(flow.next, config) : '/';
+ const { code, state, error } = req.query;
+ if (error) {
+ if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);
+ return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);
+ }
+ if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'
+ || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {
+ return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');
+ }
+ if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');
+ try {
+ const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: config.oauth.redirectUri, code_verifier: flow.verifier });
+ setSession(res, data);
+ return res.redirect(next);
+ } catch (err) {
+ log.warn('[OpenVibe.Bench] sign-in exchange failed:', err.status || '', err.error || '');
+ return res.status(err.status && err.status < 500 ? 400 : 502).type('text/plain')
+ .send(err.status && err.status < 500 ? 'The Network refused the sign-in code. Please sign in again.' : 'Sign-in failed: OpenVibe.Network did not answer. Please try again.');
+ }
+ });
+
+ const sameOriginRequest = (req) => {
+ const site = String(req.get('sec-fetch-site') || '');
+ if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');
+ const origin = req.get('origin');
+ if (origin) return origin === `${req.protocol}://${req.get('host')}`;
+ return req.method === 'GET';
+ };
+ r.get('/logout', (req, res, next) => {
+ if (sameOriginRequest(req)) return next();
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next, config))}` : '';
+ res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`
+ + `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`
+ + `<p>Sign out of OpenVibe.Bench?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);
+ });
+ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));
+ r.all('/logout', async (req, res) => {
+ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
+ if (rt) {
+ fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: rt }),
+ signal: AbortSignal.timeout(3000),
+ }).catch(() => {});
+ }
+ clearSession(res);
+ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
+ res.redirect(303, sanitizeNext(req.query.next, config));
+ });
+
+ r.get('/me', (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ const c = req.cookies || {};
+ if (!c[ACCESS_COOKIE] && !c[REFRESH_COOKIE] && !req.get('authorization')) return res.json({ user: null });
+ const v = req.viewer;
+ if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });
+ res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role, avatar_url: v.avatarUrl || null, profile_color: v.profileColor || null } });
+ });
+ return r;
+ }
+
+ return { middleware, routes, asViewer, tokenGrant, pkcePair };
+}
+
+module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE, FLOW_COOKIE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,266 @@
@@ -0,0 +1,266 @@
+'use strict';
+
+/**
+ * Bench's only model gateway: OpenVibe.AI's `generate` operation, called as the first-party service `bench` with
+ * a client-credentials token (audience openvibe.ai, scope ai.run.create; Network grants the ai.generate
+ * namespace). AI's routing is the authority: Bench reads GET /api/v1/routes?operation=generate and pins each slot
+ * as { provider, model }. The reported provenance always wins, so a fallback is never mislabelled.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+const { sanitizeHtml } = require('./sanitize');
+
+class BenchAiError extends Error {
+ constructor(status, code, detail) {
+ super(detail || code);
+ this.name = 'BenchAiError';
+ this.status = status;
+ this.code = code;
+ this.detail = detail || code;
+ }
+}
+
+function createBenchAi({ config, fetchImpl = globalThis.fetch, log = console }) {
+ const ai = config.ai;
+ const tokenClient = config.oauth.clientSecret
+ ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl || config.networkUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: ai.audience,
+ scope: ai.scope,
+ fetchImpl,
+ })
+ : null;
+ const enabled = Boolean(ai.url && tokenClient);
+
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+ async function call(method, path, body, { retried = false } = {}) {
+ if (!enabled) throw new BenchAiError(503, 'ai.not_configured', 'OpenVibe.AI is not configured for Bench.');
+ let headers;
+ try {
+ headers = { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokenClient.authHeaders()) };
+ } catch (err) {
+ throw new BenchAiError(502, 'ai.token_failed', 'OpenVibe.Network did not issue an AI token for Bench.');
+ }
+ let res;
+ try {
+ res = await fetchImpl(`${ai.url.replace(/\/+$/, '')}${path}`, {
+ method,
+ headers,
+ body: body ? JSON.stringify(body) : undefined,
+ signal: AbortSignal.timeout(ai.timeoutMs),
+ });
+ } catch (err) {
+ throw new BenchAiError(502, 'ai.unreachable', err && err.name === 'TimeoutError'
+ ? 'OpenVibe.AI did not answer in time.'
+ : 'OpenVibe.AI could not be reached.');
+ }
+ if (res.status === 401 && !retried) {
+ tokenClient.invalidate();
+ return call(method, path, body, { retried: true });
+ }
+ const json = await res.json().catch(() => null);
+ if (!res.ok) {
+ const code = (json && (json.code || json.error)) || `ai.http_${res.status}`;
+ const detail = (json && (json.detail || json.message)) || `OpenVibe.AI refused the run (${res.status}).`;
+ throw new BenchAiError(res.status >= 500 ? 502 : res.status, String(code).slice(0, 80), String(detail).slice(0, 500));
+ }
+ return json;
+ }
+
+ async function offeredModels() {
+ let body;
+ try { body = await call('GET', '/api/v1/routes?operation=generate'); } catch (err) {
+ log.warn('[OpenVibe.Bench] AI routes unavailable:', (err && err.message) || err);
+ return [];
+ }
+ let routes = Array.isArray(body) ? body : (body && Array.isArray(body.routes) ? body.routes : []);
+ if (!routes.length && body && body.routes && typeof body.routes === 'object' && !Array.isArray(body.routes)) {
+ routes = Object.entries(body.routes).map(([key, r]) => ({ key, ...(r || {}) }));
+ }
+ if (!routes.length && body && body.key) routes = [body];
+ if (!routes.length && body && Array.isArray(body.models)) {
+ routes = body.models.map((m) => ({ key: 'default.chat', capability: 'chat', primary: { provider: m.provider || m.provider_key, model: m.model || m.model_key } }));
+ }
+ const chat = routes.filter((r) => r && (r.capability === 'chat' || /chat|generate/i.test(String(r.key || ''))));
+ const ordered = [
+ ...chat.filter((r) => r.key === 'default.chat'),
+ ...chat.filter((r) => r.key !== 'default.chat'),
+ ];
+ const out = [];
+ const seen = new Set();
+ const add = (provider, model) => {
+ const p = String(provider || '').trim();
+ const m = model == null ? null : String(model).trim();
+ if (!p) return;
+ const key = `${p}|${m || ''}`;
+ if (seen.has(key)) return;
+ seen.add(key);
+ out.push({ provider: p, model: m || null });
+ };
+ const hasOffers = ordered.some((r) => Array.isArray(r.offers));
+ if (hasOffers) {
+ const preferred = ordered.find((r) => r.key === 'default.chat' && Array.isArray(r.offers));
+ const source = preferred ? [preferred] : ordered.filter((r) => Array.isArray(r.offers));
+ for (const r of source) for (const o of r.offers) add(o && o.provider, o && o.model);
+ } else {
+ for (const r of ordered) {
+ if (r.primary) add(r.primary.provider, r.primary.model);
+ for (const p of Array.isArray(r.pinned) ? r.pinned : []) add(p && p.provider, p && p.model);
+ for (const f of Array.isArray(r.fallbacks) ? r.fallbacks : []) add(f && f.provider, f && f.model);
+ }
+ }
+ return rankOffers(out);
+ }
+
+ function rankOffers(list) {
+ const out = [];
+ const seen = new Set();
+ const providers = new Set();
+ const rest = [];
+ for (const c of list) {
+ const p = String((c && c.provider) || '').trim();
+ if (!p) continue;
+ const m = c.model == null ? null : String(c.model).trim();
+ const key = `${p}|${m || ''}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ const offer = { provider: p, model: m || null };
+ if (!providers.has(p)) { providers.add(p); out.push(offer); } else rest.push(offer);
+ }
+ return [...out, ...rest];
+ }
+
+ function configuredModels() {
+ return (ai.models || []).map((s) => {
+ const i = s.indexOf(':');
+ return i > 0 ? { provider: s.slice(0, i), model: s.slice(i + 1) || null } : { provider: s, model: null };
+ });
+ }
+
+ async function slotPlan() {
+ let list = configuredModels();
+ if (!list.length) list = await offeredModels();
+ const ranked = rankOffers(list);
+ const slots = ranked.slice(0, 3);
+ const poolFallback = slots.length < 3;
+ while (slots.length < 3) slots.push({ provider: 'ai', model: null });
+ return { slots, pool_fallback: poolFallback, offered: ranked.length };
+ }
+
+ async function slots() { return (await slotPlan()).slots; }
+
+ function systemPrompt(kind) {
+ return [
+ 'You are a capable model competing in the OpenVibe.Bench benchmark.',
+ 'Answer the task the person describes, and return only the requested output with no extra explanation.',
+ `Benchmark kind: ${kind}.`,
+ ].join('\n');
+ }
+
+ function textOf(output) {
+ if (typeof output === 'string') return output;
+ if (!output || typeof output !== 'object') return '';
+ for (const k of ['text', 'html', 'content', 'body']) if (typeof output[k] === 'string') return output[k];
+ if (output.message && typeof output.message.content === 'string') return output.message.content;
+ return '';
+ }
+
+ function msOf(run, fallback) {
+ for (const v of [run && run.ms, run && run.latency_ms, run && run.duration_ms, run && run.usage && run.usage.ms, run && run.usage && run.usage.latency_ms, run && run.usage && run.usage.duration_ms]) {
+ if (Number.isFinite(Number(v)) && Number(v) >= 0) return Math.round(Number(v));
+ }
+ const start = run && (run.started_at || run.created_at);
+ const end = run && run.finished_at;
+ if (start && end) {
+ const d = Date.parse(end) - Date.parse(start);
+ if (Number.isFinite(d) && d >= 0) return Math.round(d);
+ }
+ return Math.max(0, Math.round(fallback));
+ }
+
+ async function waitFor(run, startedAt) {
+ let r = run;
+ const deadline = Date.now() + ai.timeoutMs;
+ while (r && ['queued', 'running'].includes(r.status)) {
+ if (!r.id) throw new BenchAiError(502, 'ai.bad_answer', 'OpenVibe.AI answered with a running run that has no id.');
+ if (Date.now() > deadline) throw new BenchAiError(504, 'ai.timeout', `The AI run ${r.id} did not finish in time.`);
+ await sleep(ai.pollMs);
+ const body = await call('GET', `/api/v1/runs/${encodeURIComponent(r.id)}`);
+ r = body && body.run ? body.run : body;
+ }
+ if (!r) throw new BenchAiError(502, 'ai.bad_answer', 'OpenVibe.AI answered without a run.');
+ const done = ['succeeded', 'cached', 'completed', 'ok'].includes(r.status)
+ || (r.status == null && r.output != null && !r.error);
+ if (!done) {
+ const e = r.error || {};
+ throw new BenchAiError(502, 'ai.run_failed', `The AI run ${r.id || '?'} ${r.status || 'did not finish'}${e.code ? ` (${e.code}: ${e.detail || ''})` : ''}.`);
+ }
+ return { run: r, ms: msOf(r, Date.now() - startedAt) };
+ }
+
+ async function generate({ prompt, kind, slot, roundId, index }) {
+ if (!enabled) throw new BenchAiError(503, 'ai.not_configured', 'OpenVibe.AI is not configured for Bench.');
+ const pinned = slot && slot.provider && slot.provider !== 'ai' ? { provider: slot.provider, model: slot.model || null } : null;
+ const body = {
+ prompt,
+ system: systemPrompt(kind),
+ max_tokens: 8000,
+ temperature: 0.7,
+ options: { cache: false },
+ ...(pinned ? { route: pinned } : {}),
+ target: { service: 'bench', type: 'bench_output', id: `${roundId}_${index}`.slice(0, 128) },
+ };
+ const startedAt = Date.now();
+ const wait = Math.min(ai.waitMs, 60_000);
+ let created;
+ try {
+ created = await call('POST', `/api/v1/generate?wait=${wait}`, body);
+ } catch (err) {
+ if (!err || err.status !== 404) throw err;
+ created = await call('POST', `/api/v1/runs?wait=${wait}`, {
+ workflow: 'ai.generate',
+ input: { prompt: body.prompt, system: body.system, max_tokens: body.max_tokens, temperature: body.temperature },
+ options: body.options,
+ ...(body.route ? { route: body.route } : {}),
+ target: body.target,
+ });
+ }
+ const first = created && created.run ? created.run : created;
+ const { run, ms } = await waitFor(first, startedAt);
+ const text = textOf(run.output);
+ if (!text.trim()) throw new BenchAiError(502, 'ai.empty', `The AI run ${run.id} returned no output.`);
+ const usage = run.usage || {};
+ const prov = run.provenance || {};
+ const tokens = usage.tokens && typeof usage.tokens === 'object'
+ ? (Number(usage.tokens.input) || 0) + (Number(usage.tokens.output) || 0)
+ : Number.isFinite(Number(usage.tokens)) ? Number(usage.tokens)
+ : (Number(usage.tokens_in) || 0) + (Number(usage.tokens_out) || 0)
+ || (Number(usage.input_tokens) || 0) + (Number(usage.output_tokens) || 0)
+ || (Number(run.tokens) || 0);
+ const model = String(prov.model || run.model || (run.output && run.output.model) || (slot && slot.model) || 'unknown').slice(0, 120);
+ const provider = String(prov.provider || run.provider || (slot && slot.provider) || 'ai').slice(0, 80);
+ const ui_check = run.ui_check || (run.output && run.output.ui_check) || null;
+ const html = /html_page/.test(kind || '') ? sanitizeHtml(text, { maxBytes: ai.maxHtmlBytes }) : text;
+ const payload = /html_page/.test(kind || '') ? { html } : { text };
+ return {
+ payload,
+ html,
+ cost_usd: Number(usage.cost_usd != null ? usage.cost_usd
+ : usage.cost != null ? usage.cost
+ : run.cost_usd != null ? run.cost_usd
+ : run.cost != null ? run.cost : 0) || 0,
+ ms,
+ tokens: Math.max(0, Math.round(tokens)),
+ model,
+ provider,
+ ui_check,
+ run_id: run.id || null,
+ };
+ }
+
+ return { enabled, slots, slotPlan, offeredModels, rankOffers, generate, systemPrompt };
+}
+
+module.exports = { createBenchAi, BenchAiError };
new file mode 100644
new file mode 100644
@@ -0,0 +1,152 @@
@@ -0,0 +1,152 @@
+'use strict';
+
+/**
+ * Bench's generation workers. A round (or private eval run) is created as `queued`; this loop claims it, asks
+ * OpenVibe.AI for outputs, stores each with its reported provider/model/cost/time/tokens, and marks it `finished`
+ * (at least two usable entries) or `failed`. The loop starts in server/index.js and never under BENCH_DRILL=1;
+ * tests call tick() explicitly.
+ */
+const ERROR_MAX = 500;
+
+function createBenchJobs({ s, bench, ai, config, log = console }) {
+ let timer = null;
+ let running = false;
+ let stopped = false;
+
+ async function claimRound() {
+ return await s.tx(async () => {
+ const r = await s.db.maybe("SELECT * FROM bench_rounds WHERE status = 'queued' ORDER BY created_at, id LIMIT 1 FOR UPDATE");
+ if (!r) return null;
+ await s.db.exec("UPDATE bench_rounds SET status = 'running' WHERE id = $1", [r.id]);
+ return { ...r, status: 'running' };
+ });
+ }
+
+ async function generateRound(round) {
+ const task = await bench.getTask(round.task_id);
+ if (!task) throw new Error(`round ${round.id} has no task`);
+ const kind = await bench.getKind(round.kind_id);
+ if (!kind) throw new Error(`round ${round.id} has no kind`);
+ let plan;
+ try {
+ plan = round.offers && round.offers.length
+ ? { slots: round.offers, pool_fallback: round.offers.length < 3 }
+ : (typeof ai.slotPlan === 'function' ? await ai.slotPlan() : { slots: await ai.slots(), pool_fallback: false });
+ } catch (err) {
+ log.warn('[OpenVibe.Bench] AI slots unavailable:', (err && err.message) || err);
+ plan = { slots: [{ provider: 'ai', model: null }], pool_fallback: true };
+ }
+ const slots = (Array.isArray(plan.slots) ? plan.slots : []).slice(0, 3);
+ while (slots.length < 3) slots.push({ provider: 'ai', model: null });
+ let ok = 0;
+ for (let i = 0; i < slots.length; i++) {
+ const slot = slots[i] || { provider: 'ai', model: null };
+ try {
+ const out = await ai.generate({ prompt: task.prompt, kind: kind.entry, slot, roundId: round.id, index: i });
+ await bench.addEntry({
+ roundId: round.id, provider: out.provider, model: out.model,
+ route: { provider: out.provider, model: out.model },
+ payload: out.payload, costUsd: out.cost_usd, ms: out.ms, tokens: out.tokens,
+ uiCheck: out.ui_check, runId: out.run_id,
+ });
+ ok++;
+ } catch (err) {
+ const detail = String((err && (err.detail || err.message)) || err).slice(0, ERROR_MAX);
+ log.warn(`[OpenVibe.Bench] round ${round.id} output ${i} failed:`, detail);
+ await bench.addEntry({
+ roundId: round.id,
+ provider: String((slot && slot.provider) || 'ai').slice(0,80),
+ model: String((slot && slot.model) || 'unknown').slice(0,120),
+ route: { provider: slot && slot.provider, model: slot && slot.model },
+ payload: {}, error: detail,
+ });
+ }
+ }
+ if (ok < 2) await s.db.exec("UPDATE bench_rounds SET status = 'failed' WHERE id = $1", [round.id]);
+ return ok;
+ }
+
+ async function tickRound() {
+ const round = await claimRound();
+ if (!round) return null;
+ try {
+ await generateRound(round);
+ return await bench.getRound(round.id);
+ } catch (err) {
+ log.warn('[OpenVibe.Bench] round job failed:', (err && err.message) || err);
+ await s.db.exec("UPDATE bench_rounds SET status = 'failed' WHERE id = $1", [round.id]).catch(() => {});
+ throw err;
+ }
+ }
+
+ async function claimEvalRun() {
+ return await s.tx(async () => {
+ const r = await s.db.maybe("SELECT * FROM bench_eval_runs WHERE status = 'queued' ORDER BY created_at, id LIMIT 1 FOR UPDATE");
+ if (!r) return null;
+ await s.db.exec("UPDATE bench_eval_runs SET status = 'running' WHERE id = $1", [r.id]);
+ return { ...r, status: 'running' };
+ });
+ }
+
+ async function generateEvalRun(run) {
+ const ev = await bench.getEval(run.eval_id, { owner: null });
+ if (!ev) throw new Error(`eval run ${run.id} has no eval`);
+ const results = [];
+ for (const task of (ev.test_suite || [])) {
+ const offer = (run.offers && run.offers[0]) || { provider: 'ai', model: null };
+ try {
+ const out = await ai.generate({ prompt: String(task.prompt || task), kind: (await bench.getKind(ev.kind_id)).entry, slot: offer, roundId: run.id, index: results.length });
+ results.push({ task: String(task.prompt || task).slice(0, 500), offer, provider: out.provider, model: out.model, payload: out.payload, cost_usd: out.cost_usd, ms: out.ms });
+ } catch (err) {
+ results.push({ task: String(task.prompt || task).slice(0, 500), offer, error: String((err && (err.detail || err.message)) || err).slice(0, ERROR_MAX) });
+ }
+ }
+ await s.db.exec("UPDATE bench_eval_runs SET status = 'ready', results = $1::jsonb, finished_at = $2::timestamptz WHERE id = $3",
+ [JSON.stringify(results), s.iso(), run.id]);
+ return results.length;
+ }
+
+ async function tickEval() {
+ const run = await claimEvalRun();
+ if (!run) return null;
+ try {
+ return await generateEvalRun(run);
+ } catch (err) {
+ log.warn('[OpenVibe.Bench] eval run failed:', (err && err.message) || err);
+ await s.db.exec("UPDATE bench_eval_runs SET status = 'failed' WHERE id = $1", [run.id]).catch(() => {});
+ throw err;
+ }
+ }
+
+ /** One worker pass: one queued round, then one queued eval run. */
+ async function tick() {
+ if (running || stopped) return null;
+ running = true;
+ try {
+ const round = await tickRound();
+ const evalRun = await tickEval();
+ return { round, evalRun };
+ } finally {
+ running = false;
+ }
+ }
+
+ function start() {
+ if (timer || stopped || config.drill) return null;
+ const run = () => tick().catch((err) => log.warn('[OpenVibe.Bench] job:', (err && err.message) || err));
+ timer = setInterval(run, config.bench.intervalMs);
+ if (timer.unref) timer.unref();
+ run();
+ return timer;
+ }
+
+ function stop() {
+ stopped = true;
+ if (timer) clearInterval(timer);
+ timer = null;
+ }
+
+ return { tick, tickRound, tickEval, start, stop, claimRound, claimEvalRun, generateRound, generateEvalRun, get running() { return running; } };
+}
+
+module.exports = { createBenchJobs };
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
+'use strict';
+
+/**
+ * Generated HTML safety (kept from the Design Arena and generalised: Bench stores model output as text and never
+ * puts it in its own document). Two layers:
+ *
+ * 1. sanitizeHtml(): strip <script>, on* attributes, javascript: URLs and navigation tags before storing.
+ * 2. iframeHtml(): render only inside `<iframe sandbox srcdoc="…">` with no scripts and a CSP that blocks
+ * every network fetch.
+ */
+
+const MAX_BYTES = 200 * 1024;
+const CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:";
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+const attrEscape = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+
+function urlLooksLikeScript(raw) {
+ const decoded = String(raw)
+ .replace(/&#x([0-9a-f]+);?/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
+ .replace(/&#(\d+);?/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
+ .replace(/:?/gi, ':').replace(/&tab;?/gi, '\t').replace(/&newline;?/gi, '\n');
+ return /javascript\s*:/i.test(decoded.replace(/[\s\u0000-\u001f]+/g, ''));
+}
+
+function tagEnd(s, start) {
+ let quote = null;
+ for (let i = start + 1; i < s.length; i++) {
+ const c = s[i];
+ if (quote) { if (c === quote) quote = null; }
+ else if (c === '"' || c === "'") quote = c;
+ else if (c === '>') return i;
+ }
+ return -1;
+}
+
+function sanitizeTag(tag) {
+ let t = tag;
+ t = t.replace(/([\s/])on[a-z0-9_:.-]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '$1');
+ t = t.replace(/\s(on[a-z0-9_:.-]*)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '');
+ t = t.replace(/(\b(?:href|src|action|formaction|xlink:href|poster|background|data)\s*=\s*)((?:"[^"]*"|'[^']*'|[^\s>]+))/gi,
+ (m, name, value) => (urlLooksLikeScript(value) ? `${name}"#"` : m));
+ return t;
+}
+
+function sanitizeTags(s) {
+ let out = '';
+ let i = 0;
+ while (i < s.length) {
+ const lt = s.indexOf('<', i);
+ if (lt === -1) { out += s.slice(i); break; }
+ out += s.slice(i, lt);
+ const next = s[lt + 1] || '';
+ if (!/[a-z/]/i.test(next)) { out += '<'; i = lt + 1; continue; }
+ const end = tagEnd(s, lt);
+ if (end === -1) { out += s.slice(lt); break; }
+ out += sanitizeTag(s.slice(lt, end + 1));
+ i = end + 1;
+ }
+ return out;
+}
+
+function truncateBytes(s, maxBytes) {
+ if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;
+ const buf = Buffer.from(s, 'utf8').subarray(0, maxBytes);
+ let out = buf.toString('utf8');
+ if (out.endsWith('\uFFFD')) out = out.slice(0, -1);
+ return out;
+}
+
+function sanitizeHtml(input, { maxBytes = MAX_BYTES } = {}) {
+ let s = input == null ? '' : String(input);
+ s = s.replace(/\u0000/g, '');
+ s = truncateBytes(s, maxBytes);
+ s = s.replace(/<!--[\s\S]*?-->/g, '');
+ for (let i = 0; i < 4; i++) {
+ const before = s;
+ s = s.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, '');
+ s = s.replace(/<script\b[^>]*\/\s*>/gi, '');
+ if (s === before) break;
+ }
+ s = s.replace(/<\/?script\b[^>]*>/gi, '');
+ s = s.replace(/<(\/?)(script)\b/gi, '<$1$2');
+ s = s.replace(/<base\b[^>]*>/gi, '');
+ s = s.replace(/<meta\b[^>]*http-equiv\s*=\s*(?:"refresh"|'refresh'|refresh)[^>]*>/gi, '');
+ s = s.replace(/<\/?(?:iframe|object|embed|applet)\b[^>]*>/gi, '');
+ s = sanitizeTags(s);
+ s = s.replace(/j\s*a\s*v\s*a\s*s\s*c\s*r\s*i\s*p\s*t\s*:/gi, 'blocked:');
+ if (/<script/i.test(s)) s = s.replace(/<(\/?)(script)/gi, '<$1$2');
+ return truncateBytes(s, maxBytes);
+}
+
+function frameDocument(html) {
+ return `<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`
+ + `<meta http-equiv="Content-Security-Policy" content="${CSP}">`
+ + `<style>html,body{margin:0;min-height:100%;background:#fff;}img{max-width:100%;height:auto;}</style>`
+ + sanitizeHtml(html);
+}
+
+function iframeHtml({ html = '', width = 390, height = 844, title = 'A generated output', id = null, className = '' } = {}) {
+ const w = Math.max(1, Math.round(Number(width) || 390));
+ const h = Math.max(1, Math.round(Number(height) || 844));
+ return `<iframe${id ? ` id="${esc(id)}"` : ''} class="bm-frame${className ? ` ${esc(className)}` : ''}"`
+ + ` title="${esc(title)}" width="${w}" height="${h}" data-width="${w}"`
+ + ` sandbox="" loading="lazy" referrerpolicy="no-referrer"`
+ + ` srcdoc="${attrEscape(frameDocument(html))}"></iframe>`;
+}
+
+module.exports = { sanitizeHtml, frameDocument, iframeHtml, truncateBytes, MAX_BYTES, CSP, attrEscape, urlLooksLikeScript };
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+'use strict';
+
+/**
+ * OpenVibe.Bench configuration. Every value comes from the environment (production: /etc/openvibe/bench.env).
+ * Only environment variable NAMES appear in code and docs; secrets are never logged.
+ *
+ * load(env) is pure so tests can build a config without touching process.env.
+ */
+require('dotenv').config();
+const trim = (s) => String(s || '').replace(/\/+$/, '');
+const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
+
+function load(env = process.env) {
+ const nodeEnv = env.NODE_ENV || 'development';
+ const isProduction = nodeEnv === 'production';
+ const port = int(env.PORT, 5230);
+ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://bench.openvibe.services' : `http://localhost:${port}`));
+ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
+
+ return {
+ service: 'bench',
+ name: 'OpenVibe.Bench',
+ port,
+ host: env.HOST || '127.0.0.1',
+ nodeEnv,
+ isProduction,
+ baseUrl,
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
+ drill: env.BENCH_DRILL === '1' || env.BENCH_DRILL === 'true',
+ limits: {
+ minute: Math.max(1, int(env.BENCH_LIMITS_MINUTE, 120)),
+ hour: Math.max(1, int(env.BENCH_LIMITS_HOUR, 3000)),
+ },
+
+ // PostgreSQL (ADR-035). DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
+ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.BENCH_PGLITE_DIR || '' },
+ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:bench:' },
+
+ // OpenVibe.Network: SSO and its JWKS.
+ networkUrl,
+ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
+ networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
+ audience: env.BENCH_AUDIENCE || 'openvibe.bench',
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'bench',
+ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
+ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
+ scope: 'profile',
+ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
+ },
+ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
+
+ // OpenVibe.Events → this service's outbox delivery and ADR-033 account export/deletion.
+ events: {
+ secrets: String(env.BENCH_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
+ url: trim(env.BENCH_EVENTS_URL || env.EVENTS_URL || ''),
+ endpoint: env.BENCH_EVENTS_ENDPOINT || '',
+ intervalMs: Math.max(500, int(env.BENCH_EVENTS_INTERVAL_MS, 5000)),
+ scope: env.BENCH_EVENTS_SCOPE || 'events.event.publish',
+ },
+
+ // OpenVibe.AI: the generate operation. Bench asks Network for a client-credentials token (audience
+ // openvibe.ai, scope ai.run.create; Network grants the ai.generate namespace). Generation is picked up
+ // by the job loop, never by the request that creates a round.
+ ai: {
+ url: trim(env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700'),
+ audience: env.BENCH_AI_AUDIENCE || 'openvibe.ai',
+ scope: env.BENCH_AI_SCOPE || 'ai.run.create',
+ waitMs: Math.max(1000, int(env.BENCH_AI_WAIT_MS, 55_000)),
+ timeoutMs: Math.max(1000, int(env.BENCH_AI_TIMEOUT_MS, 120_000)),
+ pollMs: Math.max(100, int(env.BENCH_AI_POLL_MS, 1500)),
+ models: String(env.BENCH_MODELS || '').split(',').map((x) => x.trim()).filter(Boolean),
+ maxHtmlBytes: 200 * 1024,
+ },
+
+ // Bench budgets and jobs.
+ bench: {
+ roundsPerDay: Math.max(1, int(env.BENCH_ROUNDS_PER_DAY, 3)),
+ roundsDaily: Math.max(1, int(env.BENCH_ROUNDS_DAILY, 20)),
+ intervalMs: Math.max(250, int(env.BENCH_JOB_INTERVAL_MS, 1500)),
+ pollMs: Math.max(250, int(env.BENCH_POLL_MS, 20_000)),
+ maxPrompt: Math.max(20, int(env.BENCH_MAX_PROMPT, 4000)),
+ evalsPerDay: Math.max(1, int(env.BENCH_EVALS_PER_DAY, 20)),
+ provisionalGames: Math.max(1, int(env.BENCH_PROVISIONAL_GAMES, 10)),
+ },
+ };
+}
+
+module.exports = { load };
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -0,0 +1,128 @@
+'use strict';
+
+/**
+ * Bench's Contracts surface, kept as JSON in the service until the published openvibe-contracts package carries
+ * the bench.* schemas and capabilities. These are the exact shapes the API, the SDK and the outbox publish, so a
+ * later move into openvibe-contracts is a copy, not a redesign.
+ */
+
+const BENCH_CAPABILITIES = Object.freeze([
+ 'bench.kind.read',
+ 'bench.task.create',
+ 'bench.round.create',
+ 'bench.round.read',
+ 'bench.entry.create',
+ 'bench.judgement.cast',
+ 'bench.board.read',
+ 'bench.dataset.read',
+ 'bench.eval.manage',
+]);
+
+const BENCH_EVENTS = Object.freeze([
+ 'bench.round.finished',
+ 'bench.judgement.recorded',
+ 'bench.rating.changed',
+]);
+
+const BENCH_SCHEMAS = Object.freeze({
+ 'bench.kind@1': {
+ type: 'object',
+ required: ['id', 'room', 'entry', 'judge', 'name', 'schema', 'metrics', 'created_at'],
+ properties: {
+ id: { type: 'string' },
+ room: { type: 'string' },
+ entry: { type: 'string' },
+ judge: { type: 'string' },
+ name: { type: 'string' },
+ schema: { type: 'object' },
+ metrics: { type: 'array', items: { type: 'string' } },
+ created_at: { type: 'string', format: 'date-time' },
+ },
+ },
+ 'bench.task@1': {
+ type: 'object',
+ required: ['id', 'kind_id', 'prompt', 'public', 'created_by', 'created_at'],
+ properties: {
+ id: { type: 'string' },
+ kind_id: { type: 'string' },
+ prompt: { type: 'string' },
+ task_class: { type: ['string', 'null'] },
+ public: { type: 'boolean' },
+ created_by: { type: 'string' },
+ created_at: { type: 'string', format: 'date-time' },
+ },
+ },
+ 'bench.round@1': {
+ type: 'object',
+ required: ['id', 'task_id', 'kind_id', 'offers', 'status', 'seed', 'created_by', 'created_at'],
+ properties: {
+ id: { type: 'string' },
+ task_id: { type: 'string' },
+ kind_id: { type: 'string' },
+ offers: { type: 'array', items: { type: 'object' } },
+ status: { type: 'string', enum: ['queued', 'running', 'ready', 'finished', 'failed'] },
+ seed: { type: 'integer' },
+ pool_fallback: { type: 'boolean' },
+ receipt: { type: ['string', 'null'] },
+ created_by: { type: 'string' },
+ created_at: { type: 'string', format: 'date-time' },
+ },
+ },
+ 'bench.entry@1': {
+ type: 'object',
+ required: ['id', 'round_id', 'provider', 'model', 'route', 'payload', 'cost_usd', 'ms', 'tokens'],
+ properties: {
+ id: { type: 'string' },
+ round_id: { type: 'string' },
+ provider: { type: 'string' },
+ model: { type: 'string' },
+ route: { type: 'object' },
+ payload: { type: 'object' },
+ cost_usd: { type: 'number' },
+ ms: { type: 'number' },
+ tokens: { type: 'number' },
+ error: { type: ['string', 'null'] },
+ ui_check: { type: ['object', 'null'] },
+ run_id: { type: ['string', 'null'] },
+ },
+ },
+ 'bench.judgement@1': {
+ type: 'object',
+ required: ['id', 'round_id', 'kind_id', 'judge', 'method', 'pair', 'outcome', 'score', 'at'],
+ properties: {
+ id: { type: 'string' },
+ round_id: { type: 'string' },
+ kind_id: { type: 'string' },
+ judge: { type: 'string' },
+ judge_name: { type: ['string', 'null'] },
+ judge_version: { type: ['string', 'null'] },
+ method: { type: 'string' },
+ pair: { type: ['object', 'null'] },
+ outcome: { type: ['string', 'null'] },
+ score: { type: ['number', 'null'] },
+ at: { type: 'string', format: 'date-time' },
+ },
+ },
+ 'bench.rating@1': {
+ type: 'object',
+ required: ['kind_id', 'category', 'subject', 'elo', 'games', 'wins', 'losses', 'ties', 'win_rate', 'ci_lower', 'ci_upper'],
+ properties: {
+ kind_id: { type: 'string' },
+ category: { type: 'string' },
+ subject: { type: 'string' },
+ elo: { type: 'number' },
+ games: { type: 'number' },
+ wins: { type: 'number' },
+ losses: { type: 'number' },
+ ties: { type: 'number' },
+ win_rate: { type: 'number' },
+ ci_lower: { type: 'number' },
+ ci_upper: { type: 'number' },
+ median_cost_usd: { type: ['number', 'null'] },
+ median_ms: { type: ['number', 'null'] },
+ provisional: { type: 'boolean' },
+ },
+ },
+});
+
+module.exports = { BENCH_CAPABILITIES, BENCH_EVENTS, BENCH_SCHEMAS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+'use strict';
+
+/**
+ * OpenVibe.Bench's own PostgreSQL database (ADR-035): migrations/NNNN_*.sql, applied at boot. Nothing written
+ * here is a secret; the open dataset is anonymous by design.
+ */
+const fs = require('fs');
+const path = require('path');
+const { createDb } = require('openvibe-sdk/db');
+const { ids } = require('openvibe-contracts');
+
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
+
+async function openDb(config, { log = console, registry } = {}) {
+ if (!config.db.url) {
+ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL');
+ const dir = config.db.pgliteDir || DEV_PGLITE;
+ log.warn(`[OpenVibe.Bench] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
+ fs.mkdirSync(dir, { recursive: true });
+ const db = createDb({ pglite: dir, service: 'bench', registry, log });
+ await db.migrate({ dir: MIGRATIONS, log });
+ return db;
+ }
+ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
+ const owner = createDb({ url: config.db.directUrl, service: 'bench-migrate', max: 1, log });
+ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+ return createDb({ url: config.db.url, service: 'bench', registry, log });
+}
+
+/** The store every module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */
+function createStore(db, { now = () => Date.now() } = {}) {
+ return {
+ db,
+ now,
+ iso: () => new Date(now()).toISOString(),
+ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,
+ tx: async (fn) => await db.tx((t) => fn(t)),
+ close: () => db.close(),
+ };
+}
+
+async function openStore(config, { now, log } = {}) {
+ return createStore(await openDb(config, { log }), { now });
+}
+
+module.exports = { openDb, openStore, createStore, MIGRATIONS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,153 @@
@@ -0,0 +1,153 @@
+'use strict';
+
+/**
+ * The transactional outbox → OpenVibe.Events. A row is written in the same transaction as the change it announces,
+ * so a retry never makes a second event. A claim lease keeps two processes from sending the same row at once.
+ * Topics: bench.round.finished, bench.judgement.recorded, bench.rating.changed.
+ */
+const crypto = require('crypto');
+const { serviceAuth } = require('openvibe-contracts');
+
+const MAX_ATTEMPTS_BACKOFF_MS = 5 * 60_000;
+
+function createPublisher({ config, s, fetchImpl = globalThis.fetch, log = console, intervalMs = config.events.intervalMs }) {
+ const db = s.db;
+ const worker = `bench-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
+ const configured = Boolean(config.events.url && config.oauth.clientSecret);
+ const scopes = [...new Set([config.events.scope || 'events.event.publish'])];
+ const clients = configured ? scopes.map((scope) => serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.events',
+ scope,
+ fetchImpl,
+ })) : [];
+ let preferred = 0;
+ async function authHeaders() {
+ for (let i = preferred; i < clients.length; i++) {
+ try {
+ const headers = await clients[i].authHeaders();
+ preferred = i;
+ return headers;
+ } catch (err) {
+ if (i === clients.length - 1) throw err;
+ }
+ }
+ throw new Error('no Events token client is configured');
+ }
+ let timer = null;
+ let running = false;
+ const stats = { published: 0, failed: 0, last_error: null };
+
+ function backoffMs(attempts) {
+ return Math.min(MAX_ATTEMPTS_BACKOFF_MS, 5000 * Math.pow(2, Math.max(0, Math.min(6, attempts))));
+ }
+
+ async function claim(limit = 20) {
+ const now = new Date(s.now()).toISOString();
+ const stale = new Date(s.now() - 2 * 60_000).toISOString();
+ return await db.many(
+ `UPDATE bench_outbox o SET claimed_at = $1::timestamptz, claimed_by = $2
+ WHERE o.event_id IN (
+ SELECT o2.event_id FROM bench_outbox o2
+ WHERE o2.published_at IS NULL AND o2.next_attempt_at <= $1::timestamptz
+ AND (o2.claimed_at IS NULL OR o2.claimed_at < $3::timestamptz)
+ ORDER BY o2.created_at ASC, o2.event_id ASC
+ LIMIT $4
+ FOR UPDATE SKIP LOCKED
+ )
+ RETURNING o.*`,
+ [now, worker, stale, Math.min(100, Math.max(1, limit))],
+ );
+ }
+
+ function envelope(row) {
+ const subjectId = row.payload && (row.payload.subject || row.payload.owner);
+ return {
+ event_id: row.event_id,
+ event_type: row.topic,
+ version: 1,
+ source: 'bench',
+ actor: { type: 'service', id: 'bench' },
+ subject: subjectId && String(subjectId).startsWith('usr_') ? { type: 'user', id: subjectId } : { type: 'service', id: 'bench' },
+ timestamp: new Date(s.now()).toISOString(),
+ priority: 'important',
+ visibility: 'internal',
+ payload: row.payload,
+ };
+ }
+
+ async function send(row) {
+ const headers = { 'Content-Type': 'application/json', Accept: 'application/json', ...(await authHeaders()) };
+ const res = await fetchImpl(`${config.events.url.replace(/\/+$/, '')}/api/v1/events`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(envelope(row)),
+ signal: AbortSignal.timeout(15_000),
+ });
+ if (!res.ok) {
+ const text = await res.text().catch(() => '');
+ throw new Error(`events ${res.status}${text ? `: ${text.slice(0, 300)}` : ''}`);
+ }
+ await db.exec('UPDATE bench_outbox SET published_at = $1::timestamptz, claimed_at = NULL, claimed_by = NULL, last_error = NULL WHERE event_id = $2',
+ [new Date(s.now()).toISOString(), row.event_id]);
+ return { ok: true };
+ }
+
+ async function runOnce({ limit = 20 } = {}) {
+ if (!configured) return { configured: false, claimed: 0, published: 0, failed: 0 };
+ if (running) return { configured: true, busy: true, claimed: 0, published: 0, failed: 0 };
+ running = true;
+ let claimed = 0;
+ let published = 0;
+ let failed = 0;
+ try {
+ const rows = await claim(limit);
+ claimed = rows.length;
+ for (const row of rows) {
+ try {
+ await send(row);
+ published++;
+ stats.published++;
+ } catch (err) {
+ failed++;
+ stats.failed++;
+ const message = String((err && err.message) || err).slice(0, 500);
+ stats.last_error = message;
+ const attempts = Number(row.attempts) + 1;
+ const next = new Date(s.now() + backoffMs(attempts)).toISOString();
+ await db.exec(
+ `UPDATE bench_outbox SET attempts = attempts + 1, last_error = $1, claimed_at = NULL, claimed_by = NULL, next_attempt_at = $2::timestamptz WHERE event_id = $3`,
+ [message, next, row.event_id],
+ );
+ log.warn(`[OpenVibe.Bench] outbox ${row.event_id} publish failed (attempt ${attempts}): ${message}`);
+ }
+ }
+ return { configured: true, claimed, published, failed };
+ } finally {
+ running = false;
+ }
+ }
+
+ function start() {
+ if (!configured || timer || config.drill) return false;
+ timer = setInterval(() => { runOnce().catch((err) => log.warn('[OpenVibe.Bench] outbox tick failed:', err.message)); }, intervalMs);
+ if (timer.unref) timer.unref();
+ return true;
+ }
+
+ function stop() {
+ if (timer) clearInterval(timer);
+ timer = null;
+ return Promise.resolve();
+ }
+
+ async function pending() {
+ return Number(await db.value('SELECT COUNT(*)::int AS n FROM bench_outbox WHERE published_at IS NULL')) || 0;
+ }
+
+ return { configured, runOnce, start, stop, pending, stats: () => ({ ...stats }) };
+}
+
+module.exports = { createPublisher, MAX_ATTEMPTS_BACKOFF_MS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,264 @@
@@ -0,0 +1,264 @@
+'use strict';
+
+/**
+ * /api/v1 — OpenVibe.Bench's JSON API.
+ *
+ * GET /kinds
+ * GET /kinds/:kindId
+ * POST /kinds/:kindId/tasks
+ * GET /tasks/:id
+ * POST /tasks/:id/rounds
+ * GET /rounds/:id
+ * POST /rounds/:id/entries (a room submits outside entries with a service token)
+ * GET /judgements/next
+ * POST /rounds/:id/judgements
+ * GET /boards/:kindId?type=elo|pareto|cost
+ * GET /models/:model
+ * GET /datasets/:kindId.jsonl
+ * POST /evals
+ * GET /evals/:id
+ * POST /evals/:id/run
+ * GET /evals/:id/results
+ *
+ * A person is the bench_at session cookie or a Network user token as a Bearer; a service sends a client-credentials
+ * token with bench.* capabilities. A session write must come from bench.openvibe.services itself.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { asyncRouter } = require('./router');
+const { sameOrigin } = require('./same-origin');
+const { kindById } = require('../kinds');
+const { BenchError } = require('../store');
+
+function createApi(ctx) {
+ const { config, s, principal, bench } = ctx;
+ const r = asyncRouter();
+
+ r.use(express.json({ limit: '64kb' }));
+ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+ r.use(principal.middleware);
+
+ const problem = (res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: res.req && res.req.ov });
+ const invalid = (res, detail) => problem(res, 400, 'bench.invalid', detail);
+ const failed = (res, err) => {
+ if (err instanceof BenchError) return problem(res, err.status, err.code, err.detail);
+ ctx.log.warn('[OpenVibe.Bench] API failed:', (err && err.message) || err);
+ return problem(res, 500, 'internal.error', 'Bench could not answer. Try again in a moment.');
+ };
+
+ const subjectOf = (req) => (req.principal && req.principal.kind === 'user' ? req.principal.subject : null);
+
+ /** A person (any signed-in user) or a service token with the named capability. */
+ function actor(cap) {
+ return (req, res, next) => {
+ const p = req.principal;
+ if (p.kind === 'app') {
+ if (p.hasCapability(cap)) return next();
+ return problem(res, 403, 'bench.capability_denied', `A service token needs ${cap}.`);
+ }
+ if (p.kind === 'user') {
+ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config)) {
+ return problem(res, 403, 'bench.cross_site', 'A signed-in request that changes something must come from bench.openvibe.services itself.');
+ }
+ return next();
+ }
+ return problem(res, 401, 'bench.sign_in_required', 'Sign in at bench.openvibe.services, or send a service token.');
+ };
+ }
+
+ const requirePerson = principal.requirePerson;
+
+ // ── Kinds ───────────────────────────────────────────────
+ r.get('/kinds', async (req, res) => {
+ const list = await bench.listKinds();
+ res.set('Cache-Control', 'public, max-age=300');
+ return res.json({ count: list.length, kinds: list });
+ });
+
+ r.get('/kinds/:kindId', async (req, res) => {
+ const kind = await bench.getKind(String(req.params.kindId || ''));
+ if (!kind) return problem(res, 404, 'bench.kind_not_found', 'No such kind.');
+ res.set('Cache-Control', 'public, max-age=300');
+ return res.json({ kind });
+ });
+
+ // ── Tasks ───────────────────────────────────────────────
+ r.post('/kinds/:kindId/tasks', actor('bench.task.create'), async (req, res) => {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const owner = subjectOf(req) || req.principal.requester;
+ const out = await bench.createTask({
+ kindId: String(req.params.kindId || ''),
+ prompt: b.prompt,
+ taskClass: b.task_class == null ? null : String(b.task_class),
+ public: Boolean(b.public),
+ subject: owner,
+ });
+ if (!out.ok) return problem(res, out.status || 400, out.code || 'bench.invalid', out.message || 'invalid task');
+ res.set('Location', `/api/v1/tasks/${out.task.id}`);
+ return res.status(201).json({ task: out.task });
+ });
+
+ r.get('/tasks/:id', async (req, res) => {
+ const task = await bench.getTask(String(req.params.id || ''));
+ if (!task) return problem(res, 404, 'bench.task_not_found', 'No such task.');
+ res.set('Cache-Control', 'public, max-age=60');
+ return res.json({ task });
+ });
+
+ // ── Rounds ──────────────────────────────────────────────
+ r.post('/tasks/:id/rounds', actor('bench.round.create'), async (req, res) => {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const owner = subjectOf(req) || req.principal.requester;
+ try {
+ const out = await bench.createRound({
+ taskId: String(req.params.id || ''),
+ offers: b.offers,
+ seed: b.seed,
+ subject: owner,
+ });
+ if (!out.ok) return problem(res, out.status || 400, out.code || 'bench.invalid', out.message || 'invalid round');
+ res.set('Location', `/api/v1/rounds/${out.round.id}`);
+ return res.status(201).json({ round: out.round });
+ } catch (err) { return failed(res, err); }
+ });
+
+ r.get('/rounds/:id', async (req, res) => {
+ try {
+ const round = await bench.getRound(String(req.params.id || ''), { viewerSubject: subjectOf(req) });
+ if (!round) return problem(res, 404, 'bench.round_not_found', 'No such round.');
+ res.set('Cache-Control', round.blind ? 'private, no-store' : 'public, max-age=60');
+ return res.json({ round });
+ } catch (err) { return failed(res, err); }
+ });
+
+ r.post('/rounds/:id/entries', principal.requireCapability('bench.entry.create'), async (req, res) => {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ try {
+ const entry = await bench.addEntry({
+ roundId: String(req.params.id || ''),
+ provider: b.provider,
+ model: b.model,
+ route: b.route,
+ payload: b.payload,
+ costUsd: b.cost_usd,
+ ms: b.ms,
+ tokens: b.tokens,
+ error: b.error,
+ uiCheck: b.ui_check,
+ runId: b.run_id,
+ });
+ res.set('Location', `/api/v1/rounds/${String(req.params.id)}`);
+ return res.status(201).json({ entry });
+ } catch (err) { return failed(res, err); }
+ });
+
+ // ── Judgements ──────────────────────────────────────────
+ r.get('/judgements/next', requirePerson, async (req, res) => {
+ try {
+ const pair = await bench.nextJudgement({ kind: String(req.query.kind || ''), subject: subjectOf(req), random: ctx.random || Math.random });
+ res.set('Cache-Control', 'private, no-store');
+ if (!pair) return res.json({ pair: null, detail: 'There is no round waiting for your judgement right now.' });
+ return res.json(pair);
+ } catch (err) { return failed(res, err); }
+ });
+
+ r.post('/rounds/:id/judgements', async (req, res) => {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const roundId = String(req.params.id || '');
+ let judge = typeof b.judge === 'string' && b.judge ? b.judge : null;
+ if (!judge) {
+ const round = await bench.getRound(roundId, { viewerSubject: null });
+ if (!round) return problem(res, 404, 'bench.round_not_found', 'No such round.');
+ judge = (kindById(round.kind_id) || {}).judge || null;
+ }
+ if (!judge) return invalid(res, 'judge is required');
+ const isHuman = judge === 'human_pairwise' || judge === 'rubric';
+ const guard = isHuman ? requirePerson : principal.requireCapability('bench.judgement.cast');
+ return guard(req, res, async () => {
+ try {
+ const subject = isHuman ? subjectOf(req) : req.principal.requester;
+ const out = await bench.castJudgement({
+ roundId, kindId: b.kind_id, judge,
+ judgeName: b.judge_name, judgeVersion: b.judge_version,
+ pair: b.pair, outcome: b.outcome, score: b.score, subject,
+ });
+ res.set('Cache-Control', 'private, no-store');
+ return res.status(201).json(out);
+ } catch (err) { return failed(res, err); }
+ });
+ });
+
+ // ── Boards, models, datasets ────────────────────────────
+ r.get('/boards/:kindId', async (req, res) => {
+ try {
+ const type = typeof req.query.type === 'string' && req.query.type ? req.query.type : 'elo';
+ const board = await bench.board(String(req.params.kindId || ''), {
+ type,
+ category: typeof req.query.category === 'string' ? req.query.category : 'all',
+ period: typeof req.query.period === 'string' ? req.query.period : 'all',
+ });
+ if (!board) return problem(res, 404, 'bench.kind_not_found', 'No such kind.');
+ res.set('Cache-Control', 'public, max-age=30');
+ return res.json({ board });
+ } catch (err) { return failed(res, err); }
+ });
+
+ r.get('/models/:model', async (req, res) => {
+ try {
+ const out = await bench.modelSummary(String(req.params.model || ''));
+ if (!out) return problem(res, 404, 'bench.model_not_found', 'No such model.');
+ res.set('Cache-Control', 'public, max-age=60');
+ return res.json(out);
+ } catch (err) { return failed(res, err); }
+ });
+
+ r.get('/datasets/:kindId.jsonl', async (req, res) => {
+ try {
+ const text = await bench.datasetJsonl(String(req.params.kindId || ''));
+ if (text == null) return problem(res, 404, 'bench.kind_not_found', 'No such kind.');
+ res.set('Cache-Control', 'public, max-age=60');
+ res.set('Content-Disposition', `attachment; filename="${String(req.params.kindId || 'bench')}-dataset.jsonl"`);
+ return res.type('application/x-ndjson').send(text);
+ } catch (err) { return failed(res, err); }
+ });
+
+ // ── Private evals ───────────────────────────────────────
+ r.post('/evals', actor('bench.eval.manage'), async (req, res) => {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const owner = subjectOf(req) || req.principal.requester;
+ const out = await bench.createEval({ owner, kindId: b.kind_id, name: b.name, testSuite: b.test_suite });
+ if (!out.ok) return problem(res, out.status || 400, out.code || 'bench.invalid', out.message || 'invalid eval');
+ res.set('Location', `/api/v1/evals/${out.eval.id}`);
+ return res.status(201).json({ eval: out.eval });
+ });
+
+ r.get('/evals/:id', actor('bench.eval.manage'), async (req, res) => {
+ const owner = subjectOf(req) || req.principal.requester;
+ const ev = await bench.getEval(String(req.params.id || ''), { owner });
+ if (!ev) return problem(res, 404, 'bench.eval_not_found', 'No such eval (or it is not yours).');
+ res.set('Cache-Control', 'private, no-store');
+ return res.json({ eval: ev });
+ });
+
+ r.post('/evals/:id/run', actor('bench.eval.manage'), async (req, res) => {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const owner = subjectOf(req) || req.principal.requester;
+ try {
+ const run = await bench.runEval({ evalId: String(req.params.id || ''), offers: b.offers, owner });
+ res.set('Cache-Control', 'private, no-store');
+ return res.status(202).json({ run });
+ } catch (err) { return failed(res, err); }
+ });
+
+ r.get('/evals/:id/results', actor('bench.eval.manage'), async (req, res) => {
+ const owner = subjectOf(req) || req.principal.requester;
+ const out = await bench.evalResults(String(req.params.id || ''), { owner });
+ if (!out) return problem(res, 404, 'bench.eval_not_found', 'No such eval (or it is not yours).');
+ res.set('Cache-Control', 'private, no-store');
+ return res.json(out);
+ });
+
+ return r;
+}
+
+module.exports = { createApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+
+/**
+ * Crawl artifacts for bench.openvibe.services. Sign-in, the API and the internal route are not for crawlers.
+ */
+const fs = require('fs');
+const path = require('path');
+const seo = require('openvibe-shared/seo');
+const cache = require('openvibe-shared/cache-policy');
+const { asyncRouter } = require('./router');
+
+const SITE_NAME = 'OpenVibe.Bench';
+const DESCRIPTION = 'OpenVibe.Bench is one AI benchmark and comparison engine for every room: blind votes, rubrics, graders, test suites and game outcomes, with reproducible pinned rounds and an open dataset per kind.';
+const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics', '/mcp'];
+
+function dayOf(ts) {
+ const m = String(ts == null ? '' : ts).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 homeJsonLd(config) {
+ const site = String(config.baseUrl).replace(/\/+$/, '');
+ return [
+ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
+ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
+ ];
+}
+
+const PAGES = [
+ { path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Bench home', text: 'One benchmark engine for every room: the kinds, boards and open dataset.' },
+ { path: '/docs', changefreq: 'weekly', priority: 0.6, title: 'The Bench API', text: 'The Bench JSON API, boards, dataset and MCP tools.' },
+];
+
+function createDiscoveryRoutes(ctx) {
+ const { config } = ctx;
+ const r = asyncRouter();
+ const site = String(config.baseUrl).replace(/\/+$/, '');
+ const abs = (p) => `${site}${p}`;
+ const TEXT = cache.htmlHeaders({ maxAge: 3600 });
+
+ r.get('/robots.txt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', TEXT).send(
+ '# bench.openvibe.services: boards and datasets are JSON; sign-in, the API and the internal route are not for crawlers.\n'
+ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
+ });
+
+ r.get('/llms.txt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
+ name: SITE_NAME,
+ summary: DESCRIPTION,
+ details: 'GET /api/v1/kinds lists the fifteen benchmark kinds. GET /api/v1/boards/:kind?type=elo|pareto|cost renders a board as JSON. GET /api/v1/datasets/:kind.jsonl is the open dataset without voter ids. POST /mcp is the Model Context Protocol endpoint.',
+ sections: [
+ { title: 'Start here', links: [{ title: SITE_NAME, url: abs('/') }, { title: 'The API', url: abs('/docs') }] },
+ { title: 'Machine-readable', links: [{ title: 'Kinds (JSON)', url: abs('/api/v1/kinds') }, { title: 'Sitemap', url: abs('/sitemap.xml') }, { title: 'Release metadata (JSON)', url: abs('/release.json') }] },
+ ],
+ }));
+ });
+
+ r.get('/llms-full.txt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
+ site: SITE_NAME,
+ summary: 'The Bench home and its API docs.',
+ base: site,
+ maxBytes: 256 * 1024,
+ sections: [{ title: 'Pages', pages: PAGES.map((p) => ({ url: p.path, title: p.title, text: p.text })) }],
+ }));
+ });
+
+ r.get('/sitemap.xml', (_req, res) => {
+ const lastmod = siteUpdated();
+ const urls = PAGES.map((p) => ({ loc: abs(p.path), ...(lastmod ? { lastmod } : {}), changefreq: p.changefreq, priority: p.priority }));
+ res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
+ });
+
+ return r;
+}
+
+module.exports = { createDiscoveryRoutes, homeJsonLd, PAGES, DESCRIPTION, SITE_NAME };
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+'use strict';
+
+/**
+ * Bench's server-rendered pages: a plain home and the API docs. Public boards are JSON only (Report renders their
+ * HTML later).
+ */
+const express = require('express');
+const seo = require('openvibe-shared/seo');
+const { asyncRouter } = require('./router');
+const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
+const { raw } = require('../render/html');
+const { send } = require('../render/layout');
+const views = require('../views');
+
+const PUBLIC_CACHE = 'public, max-age=60';
+
+function createPageRoutes(ctx) {
+ const { config, bench } = ctx;
+ const r = asyncRouter();
+ const base = () => String(config.baseUrl).replace(/\/+$/, '');
+ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
+
+ r.get('/', async (req, res) => {
+ const kinds = await bench.listKinds();
+ return page(req, res, {
+ index: true, cache: PUBLIC_CACHE,
+ title: null,
+ description: 'OpenVibe.Bench is one AI benchmark and comparison engine for every room: blind votes, rubrics, graders, test suites and game outcomes, with reproducible pinned rounds and an open dataset per kind.',
+ jsonLd: homeJsonLd(config),
+ body: raw(views.home({ kinds, counts: { kinds: kinds.length } })),
+ });
+ });
+
+ r.get('/docs', (req, res) => page(req, res, {
+ index: true, cache: PUBLIC_CACHE,
+ title: 'The Bench API',
+ description: 'The Bench JSON API, the boards, the open dataset and the MCP tools.',
+ jsonLd: [seo.jsonLd.webPage({ name: 'The Bench API', url: `${base()}/docs`, description: 'The Bench JSON API, the boards, the open dataset and the MCP tools.', siteUrl: base() })],
+ body: raw(views.docs()),
+ }));
+
+ r.use(createDiscoveryRoutes(ctx));
+
+ return r;
+}
+
+module.exports = { createPageRoutes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+'use strict';
+
+/**
+ * Who is calling /api/v1 — req.principal:
+ *
+ * { kind: 'user', subject: 'usr_…', username, role, token, viaSession }
+ * a person: their Network user token as a Bearer, or this site's session cookie (bench_at).
+ * { kind: 'app', requester: 'service:…' | 'app:…' | 'agent:…', project, claims, capabilities }
+ * a Network service token for audience openvibe.bench with bench.* capabilities.
+ * { kind: 'anonymous' }
+ *
+ * A request that presents a token is judged on that token alone. A session-cookie write must come from this site
+ * (Sec-Fetch-Site same-origin, or an Origin equal to ours).
+ */
+const { serviceAuth, http, ids } = require('openvibe-contracts');
+const { BENCH_CAPABILITIES } = require('../contracts');
+const { sameOrigin } = require('./same-origin');
+
+const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+const CAPABILITIES = Object.freeze([...BENCH_CAPABILITIES]);
+
+function decodePayload(token) {
+ const parts = String(token || '').split('.');
+ if (parts.length !== 3) return null;
+ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }
+}
+
+function roleClaim(claims) {
+ return typeof claims.role === 'string' && claims.role ? claims.role.slice(0, 32) : 'user';
+}
+function usernameClaim(claims) {
+ return typeof claims.username === 'string' ? claims.username.slice(0, 64) : null;
+}
+
+function requesterOfSub(sub) {
+ if (sub.startsWith('svc:')) return `service:${sub.slice(4)}`;
+ return sub;
+}
+
+function createPrincipal({ config, keys }) {
+ async function resolve(req) {
+ const header = String(req.headers.authorization || '');
+ if (header.startsWith('Bearer ')) {
+ const token = header.slice(7).trim();
+ const payload = decodePayload(token);
+ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
+ const publicKey = await keys.pemForToken(token);
+ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
+ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });
+ if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };
+ const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;
+ const capabilities = Array.isArray(r.claims.cap) ? r.claims.cap : [];
+ return {
+ principal: {
+ kind: 'app', requester: requesterOfSub(r.claims.sub), project, claims: r.claims,
+ capabilities, hasCapability: (cap) => capabilities.includes(cap),
+ },
+ };
+ }
+ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience });
+ if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };
+ if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };
+ if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
+ return { principal: { kind: 'user', subject: v.claims.subject_id, requester: `user:${v.claims.subject_id}`, project: null, role: roleClaim(v.claims), username: usernameClaim(v.claims), token, viaSession: false } };
+ }
+ const viewer = req.viewer;
+ if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {
+ return { principal: { kind: 'user', subject: viewer.subject, requester: `user:${viewer.subject}`, project: null, role: viewer.role || 'user', username: viewer.username || null, token: viewer.token, viaSession: true } };
+ }
+ return { principal: { kind: 'anonymous' } };
+ }
+
+ async function middleware(req, res, next) {
+ const r = await resolve(req);
+ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });
+ req.principal = r.principal;
+ return next();
+ }
+
+ function requireCapability(cap) {
+ if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.Bench's capabilities`);
+ return function guard(req, res, next) {
+ const p = req.principal;
+ if (p.kind === 'app' && p.hasCapability(cap)) return next();
+ if (p.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Send a person\'s Network token as a Bearer, or a service token with bench.* capabilities.', ctx: req.ov });
+ if (p.kind === 'user') return http.sendProblem(res, 403, 'bench.capability_required', { detail: `This route needs the ${cap} capability on a service token.`, ctx: req.ov });
+ return http.sendProblem(res, 403, 'bench.capability_required', { detail: `This route needs the ${cap} capability.`, ctx: req.ov });
+ };
+ }
+
+ function requirePerson(req, res, next) {
+ const p = req.principal;
+ if (p.kind === 'anonymous') return http.sendProblem(res, 401, 'bench.sign_in_required', { detail: 'Sign in at bench.openvibe.services, or send a person\'s Network token as a Bearer.', ctx: req.ov });
+ if (p.kind !== 'user') return http.sendProblem(res, 403, 'bench.person_required', { detail: 'This route is for a person, not a service token.', ctx: req.ov });
+ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config)) {
+ return http.sendProblem(res, 403, 'bench.cross_site', { detail: 'A signed-in request that changes something must come from bench.openvibe.services itself.', ctx: req.ov });
+ }
+ return next();
+ }
+
+ return { middleware, resolve, requireCapability, requirePerson };
+}
+
+module.exports = { createPrincipal, CAPABILITIES, decodePayload };
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+'use strict';
+
+/**
+ * express.Router() whose get/post/patch/delete handlers may be async: a rejected promise goes to next(err).
+ */
+const express = require('express');
+
+function asyncRouter() {
+ const r = express.Router();
+ for (const method of ['get', 'post', 'patch', 'delete', 'put', 'all']) {
+ const orig = r[method].bind(r);
+ r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {
+ try {
+ const out = h(req, res, next);
+ if (out && typeof out.catch === 'function') out.catch(next);
+ } catch (err) { next(err); }
+ })));
+ }
+ return r;
+}
+
+module.exports = { asyncRouter };
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+
+/**
+ * Same-origin writes (CSRF). A write authenticated by the httpOnly `bench_at` session cookie must come from this
+ * site: Sec-Fetch-Site: same-origin, or an Origin equal to BASE_URL's origin. A Bearer write is not
+ * cookie-authenticated and is unaffected.
+ */
+const { http } = require('openvibe-contracts');
+const { ACCESS_COOKIE } = require('../auth/sso');
+
+function ownOrigin(config) {
+ try { return new URL(config.baseUrl).origin; } catch { return null; }
+}
+
+function bearer(req) {
+ const h = String(req.get('authorization') || '');
+ return /^Bearer\s+\S/i.test(h) ? h.replace(/^Bearer\s+/i, '').trim() : null;
+}
+
+function cookieAuthenticated(req) {
+ return Boolean(req.cookies && req.cookies[ACCESS_COOKIE]);
+}
+
+function sameOrigin(req, config) {
+ const site = String(req.get('sec-fetch-site') || '').trim().toLowerCase();
+ if (site === 'same-origin') return true;
+ const sent = String(req.get('origin') || '');
+ const own = ownOrigin(config);
+ return Boolean(sent) && Boolean(own) && sent === own;
+}
+
+function writeAllowed(req, config) {
+ if (bearer(req)) return true;
+ if (!cookieAuthenticated(req)) return true;
+ return sameOrigin(req, config);
+}
+
+function requireSameOrigin(config, { detail = 'A change must come from this site itself.', code = 'bench.cross_site' } = {}) {
+ return (req, res, next) => {
+ if (writeAllowed(req, config)) return next();
+ return http.sendProblem(res, 403, code, { detail, ctx: req.ov });
+ };
+}
+
+module.exports = { ownOrigin, bearer, cookieAuthenticated, sameOrigin, writeAllowed, requireSameOrigin };
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+'use strict';
+
+/**
+ * Account export and deletion → Bench (ADR-033; openvibe-sdk/account-data). The rows a person owns are the tasks
+ * and rounds they started, the judgements they cast and the private evals they own. Ratings are aggregate and name
+ * no person, so they stay; a public dataset judgement never carries a voter id.
+ */
+const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+
+const TABLES = [
+ { table: 'bench_judgements', subject: 'subject', file: 'bench_judgements.json', erase: { anonymize: {} } },
+ { table: 'bench_rounds', subject: 'created_by', file: 'bench_rounds.json', erase: 'delete' },
+ { table: 'bench_tasks', subject: 'created_by', file: 'bench_tasks.json', erase: 'delete' },
+ { table: 'bench_evals', subject: 'owner', file: 'bench_evals.json', erase: 'delete' },
+];
+
+function create({ db, log = console } = {}) {
+ return createAccountData({
+ db,
+ service: 'bench',
+ tables: TABLES,
+ note: 'Bench exports your tasks, rounds, judgements and private evals; the public dataset is anonymous and a rating never names a person.',
+ log,
+ });
+}
+
+module.exports = { create, TABLES, TOPICS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+'use strict';
+
+/**
+ * Who is staff at OpenVibe.Bench: the Network role claim on a person's session. contracts.staff.atLeast is the one
+ * place the ranking lives.
+ */
+const { staff } = require('openvibe-contracts');
+
+const STAFF_MIN = 'global_mod';
+
+function isStaff(who) {
+ if (!who || typeof who !== 'object' || who.kind !== 'user') return false;
+ if (typeof who.role !== 'string' || !who.role) return false;
+ try { return staff.atLeast(who.role, STAFF_MIN); } catch { return false; }
+}
+
+module.exports = { isStaff, STAFF_MIN };
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+
+/**
+ * OpenVibe.Bench — process entry. `node server/index.js` listens on PORT (5230) behind nginx (deploy/).
+ */
+const { createApp } = require('./app');
+const { gracefulStop } = require('openvibe-sdk/service');
+const { startTelemetryRetention } = require('openvibe-sdk/telemetry');
+const { startSubscriptions } = require('openvibe-sdk/account-data');
+
+function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
+ return gracefulStop({
+ name: 'OpenVibe.Bench', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
+ close: [
+ () => { for (const t of timers) clearInterval(t); },
+ () => ctx.keys.client.stop(),
+ () => ctx.jobs.stop(),
+ () => ctx.publisher.stop(),
+ async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },
+ () => ctx.s.close(),
+ ...extra,
+ ],
+ });
+}
+
+async function start() {
+ const { app, ctx } = await createApp();
+ const { config } = ctx;
+
+ const server = app.listen(config.port, config.host, () => {
+ console.log(`[OpenVibe.Bench] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.s.db.store})`);
+ ctx.keys.ensure().catch(() => {});
+ });
+ server.keepAliveTimeout = 65_000;
+ ctx.retention = startTelemetryRetention({ db: ctx.s.db, log: ctx.log || console, olderThanDays: 14 });
+ ctx.telemetryTick = setInterval(() => { ctx.telemetry.flush().catch(() => {}); }, 60_000);
+ ctx.telemetryTick.unref();
+ ctx.keys.client.start();
+
+ // Jobs: rounds and private eval runs. Off under BENCH_DRILL; tests call tick().
+ ctx.jobs.start();
+ // Outbox publisher → OpenVibe.Events.
+ ctx.publisher.start();
+
+ const subs = config.drill ? null : startSubscriptions({
+ eventsUrl: config.events.url,
+ endpoint: config.events.endpoint || `http://127.0.0.1:${config.port}/internal/events`,
+ secret: config.events.secrets[0] || '',
+ networkInternalUrl: config.networkInternalUrl,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ log: ctx.log || console,
+ });
+ const extra = [() => { if (subs) subs.stop(); }];
+ createLifecycle({ server, ctx, extra });
+ return { server, ctx };
+}
+
+if (require.main === module) {
+ start().catch((err) => { console.error('[OpenVibe.Bench] failed to start:', err); process.exit(1); });
+}
+
+module.exports = { start, createLifecycle };
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+'use strict';
+
+/**
+ * The fifteen kinds Bench launches with (plan 2026-10-28 §4.12). Each names the room that plugs into it, the
+ * entry payload type the room/AI produces, and the judge that ranks entries. A kind's `schema` is the JSON shape
+ * of an entry payload and its `metrics` are the axes a board may show.
+ */
+
+const JUDGES = Object.freeze(['human_pairwise', 'rubric', 'automatic_grader', 'test_suite', 'game_outcome']);
+
+/** Pairwise winners as the blind voter saw them; both_bad is a game for neither. */
+const WINNERS = Object.freeze(['left', 'right', 'tie', 'both_bad']);
+
+const KINDS = Object.freeze([
+ { id: 'design.page', room: 'design', entry: 'html_page', judge: 'human_pairwise', name: 'Design page arena', metrics: ['elo', 'cost_usd', 'ms'], schema: { entry: 'html_page', payload: { html: 'string' } } },
+ { id: 'design.component', room: 'design', entry: 'html_page', judge: 'human_pairwise', name: 'Design component arena', metrics: ['elo', 'cost_usd', 'ms'], schema: { entry: 'html_page', payload: { html: 'string' } } },
+ { id: 'games.match', room: 'games', entry: 'game_match', judge: 'game_outcome', name: 'AI vs AI games', metrics: ['cost_usd', 'ms'], schema: { entry: 'game_match', payload: { result: 'object' } } },
+ { id: 'games.fun', room: 'games', entry: 'game_match', judge: 'human_pairwise', name: 'Most fun play', metrics: ['elo', 'cost_usd'], schema: { entry: 'game_match', payload: { result: 'object' } } },
+ { id: 'meme.caption', room: 'meme', entry: 'image', judge: 'human_pairwise', name: 'Meme caption arena', metrics: ['elo', 'cost_usd'], schema: { entry: 'image', payload: { image: 'string', caption: 'string' } } },
+ { id: 'vision.answer', room: 'vision', entry: 'model_answer', judge: 'automatic_grader', name: 'Vision answers', metrics: ['pass_rate', 'cost_usd'], schema: { entry: 'model_answer', payload: { answer: 'string' } } },
+ { id: 'vision.pair', room: 'vision', entry: 'model_answer', judge: 'human_pairwise', name: 'Vision head-to-head', metrics: ['elo', 'cost_usd'], schema: { entry: 'model_answer', payload: { answer: 'string' } } },
+ { id: 'codes.patch', room: 'codes', entry: 'code_patch', judge: 'test_suite', name: 'Code patches', metrics: ['pass_rate', 'cost_usd'], schema: { entry: 'code_patch', payload: { patch: 'string' } } },
+ { id: 'codes.review', room: 'codes', entry: 'code_patch', judge: 'human_pairwise', name: 'Code diff review', metrics: ['elo', 'cost_usd'], schema: { entry: 'code_patch', payload: { patch: 'string' } } },
+ { id: 'surf.desk', room: 'surf', entry: 'agent_on_desk', judge: 'human_pairwise', name: 'Agents on desks', metrics: ['elo', 'cost_usd', 'ms'], schema: { entry: 'agent_on_desk', payload: { desk: 'object' } } },
+ { id: 'chat.reply', room: 'chat', entry: 'conversation_turn', judge: 'human_pairwise', name: 'Chat replies', metrics: ['elo', 'cost_usd'], schema: { entry: 'conversation_turn', payload: { reply: 'string' } } },
+ { id: 'speech.tts', room: 'speech', entry: 'audio', judge: 'human_pairwise', name: 'Text to speech voices', metrics: ['elo', 'cost_usd'], schema: { entry: 'audio', payload: { audio_url: 'string' } } },
+ { id: 'speech.asr', room: 'speech', entry: 'transcript', judge: 'automatic_grader', name: 'Speech recognition', metrics: ['word_error_rate', 'cost_usd'], schema: { entry: 'transcript', payload: { transcript: 'string' } } },
+ { id: 'translate.ja', room: 'translate', entry: 'translation', judge: 'human_pairwise', name: 'Japanese translation', metrics: ['elo', 'cost_usd'], schema: { entry: 'translation', payload: { translation: 'string' } } },
+ { id: 'search.answer', room: 'search', entry: 'cited_answer', judge: 'human_pairwise', name: 'Cited answers', metrics: ['elo', 'cost_usd'], schema: { entry: 'cited_answer', payload: { answer: 'string', citations: 'array' } } },
+]);
+
+const byId = new Map(KINDS.map((k) => [k.id, k]));
+
+module.exports = { KINDS, JUDGES, WINNERS, kindById: (id) => byId.get(id) || null };
new file mode 100644
new file mode 100644
@@ -0,0 +1,229 @@
@@ -0,0 +1,229 @@
+'use strict';
+
+/**
+ * Bench as a Model Context Protocol server (Streamable HTTP), POST /mcp — modelled on OpenVibe.Search's endpoint.
+ * Stateless JSON-RPC 2.0: initialize, ping, tools/list and tools/call, single requests or batches, CORS preflight,
+ * and a JSON-RPC parse error for malformed bodies.
+ *
+ * The six tools are the §4.12 list. A person is a Network user token as a Bearer (or the bench_at session cookie).
+ */
+
+const express = require('express');
+const pkg = require('../package.json');
+
+const PROTOCOL_VERSION = '2025-06-18';
+const SUPPORTED_VERSIONS = Object.freeze(new Set(['2025-06-18', '2025-03-26']));
+const BODY_LIMIT = '64kb';
+
+const INSTRUCTIONS = 'Bench runs one shared benchmark engine across every OpenVibe room. list_kinds shows the fifteen kinds; start_round queues a pinned round and returns its id; next_pair fetches a blind pair to judge; cast_judgement records a vote and reveals the two entries; get_board renders an elo, pareto or cost board; run_eval runs a private task suite.';
+
+function toolsInput() {
+ return {
+ list_kinds: { type: 'object', properties: {} },
+ start_round: {
+ type: 'object',
+ required: ['kind', 'prompt'],
+ properties: {
+ kind: { type: 'string', description: 'A kind id, e.g. design.page.' },
+ prompt: { type: 'string', description: 'The task prompt.' },
+ offers: { type: 'array', items: { type: 'object' }, description: 'Optional pinned {provider, model} slots.' },
+ seed: { type: 'integer' },
+ },
+ },
+ next_pair: {
+ type: 'object',
+ required: ['kind'],
+ properties: { kind: { type: 'string' }, judge: { type: 'string' } },
+ },
+ cast_judgement: {
+ type: 'object',
+ required: ['round', 'left', 'right', 'outcome'],
+ properties: {
+ round: { type: 'string' },
+ left: { type: 'string' },
+ right: { type: 'string' },
+ outcome: { type: 'string', enum: ['left', 'right', 'tie', 'both_bad'] },
+ },
+ },
+ get_board: {
+ type: 'object',
+ required: ['kind'],
+ properties: {
+ kind: { type: 'string' },
+ type: { type: 'string', enum: ['elo', 'pareto', 'cost'], default: 'elo' },
+ category: { type: 'string' },
+ period: { type: 'string' },
+ },
+ },
+ run_eval: {
+ type: 'object',
+ required: ['kind', 'test_suite'],
+ properties: {
+ kind: { type: 'string' },
+ test_suite: { type: 'array', items: { type: 'object' } },
+ offers: { type: 'array', items: { type: 'object' } },
+ },
+ },
+ };
+}
+
+const TOOL_META = Object.freeze([
+ { name: 'bench.list_kinds', title: 'List Bench kinds', description: 'List the fifteen benchmark kinds Bench runs for every room.' },
+ { name: 'bench.start_round', title: 'Start a Bench round', description: 'Create a task and queue a pinned round for it.' },
+ { name: 'bench.next_pair', title: 'Next blind pair', description: 'Fetch the next ready round this person has not judged, with the two entries shuffled and identities hidden.' },
+ { name: 'bench.cast_judgement', title: 'Cast a judgement', description: 'Record a blind pairwise vote and reveal the two entries.' },
+ { name: 'bench.get_board', title: 'Get a board', description: 'Render an elo, pareto or cost board for one kind.' },
+ { name: 'bench.run_eval', title: 'Run a private eval', description: 'Create and run a private task suite against chosen offers.' },
+]);
+
+const TOOLS = Object.freeze(TOOL_META.map((t) => ({ ...t, inputSchema: toolsInput()[t.name.slice(6)] })));
+
+function result(id, value) { return { jsonrpc: '2.0', id, result: value }; }
+function error(id, code, message) { return { jsonrpc: '2.0', id: id === undefined ? null : id, error: { code, message } }; }
+function validId(id) { return id === null || typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id)); }
+
+function subjectOf(req) {
+ return req.principal && req.principal.kind === 'user' && req.principal.subject ? req.principal.subject : null;
+}
+
+function requirePersonTool(id, req) {
+ if (subjectOf(req)) return null;
+ return { response: result(id, { content: [{ type: 'text', text: 'This tool needs a person: sign in, or send a Network user token as a Bearer.' }], isError: true }) };
+}
+
+function rowText(obj) {
+ return Object.entries(obj || {}).map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`).join(' · ');
+}
+
+async function callTool(id, params, req, ctx) {
+ const { bench } = ctx;
+ const name = params.name;
+ const tool = TOOLS.find((t) => t.name === name);
+ if (!tool) return { response: error(id, -32602, 'Unknown tool') };
+ const args = params.arguments && typeof params.arguments === 'object' && !Array.isArray(params.arguments) ? params.arguments : {};
+
+ switch (name) {
+ case 'bench.list_kinds': {
+ const kinds = await bench.listKinds();
+ return { response: result(id, { content: [{ type: 'text', text: kinds.map((k) => `${k.id}: ${k.name} (${k.room}, ${k.judge})`).join('\n') }], structuredContent: { kinds }, isError: false }) };
+ }
+ case 'bench.start_round': {
+ const denied = requirePersonTool(id, req);
+ if (denied) return denied;
+ const owner = subjectOf(req);
+ const task = await bench.createTask({ kindId: args.kind, prompt: args.prompt, public: true, subject: owner });
+ if (!task.ok) return { response: result(id, { content: [{ type: 'text', text: task.message }], isError: true }) };
+ const out = await bench.createRound({ taskId: task.task.id, offers: args.offers, seed: args.seed, subject: owner });
+ if (!out.ok) return { response: result(id, { content: [{ type: 'text', text: out.message }], isError: true }) };
+ return { response: result(id, { content: [{ type: 'text', text: rowText(out.round) }], structuredContent: out.round, isError: false }) };
+ }
+ case 'bench.next_pair': {
+ const denied = requirePersonTool(id, req);
+ if (denied) return denied;
+ const pair = await bench.nextJudgement({ kind: args.kind, subject: subjectOf(req) });
+ if (!pair) return { response: result(id, { content: [{ type: 'text', text: 'No round waiting for your judgement.' }], isError: false }) };
+ return { response: result(id, { content: [{ type: 'text', text: `Round ${pair.round.id}: choose left or right.` }], structuredContent: pair, isError: false }) };
+ }
+ case 'bench.cast_judgement': {
+ const denied = requirePersonTool(id, req);
+ if (denied) return denied;
+ const out = await bench.castJudgement({
+ roundId: args.round, kindId: args.kind, judge: 'human_pairwise',
+ pair: { left: args.left, right: args.right }, outcome: args.outcome, subject: subjectOf(req),
+ });
+ return { response: result(id, { content: [{ type: 'text', text: rowText(out.judgement) }], structuredContent: out, isError: false }) };
+ }
+ case 'bench.get_board': {
+ const board = await bench.board(args.kind, { type: args.type || 'elo', category: args.category || 'all', period: args.period || 'all' });
+ if (!board) return { response: result(id, { content: [{ type: 'text', text: 'No such kind.' }], isError: true }) };
+ const lines = board.rows.map((r) => rowText(r));
+ return { response: result(id, { content: [{ type: 'text', text: lines.join('\n') || 'No rows yet.' }], structuredContent: { board }, isError: false }) };
+ }
+ case 'bench.run_eval': {
+ const denied = requirePersonTool(id, req);
+ if (denied) return denied;
+ const owner = subjectOf(req);
+ const ev = await bench.createEval({ owner, kindId: args.kind, name: `MCP eval ${args.kind}`, testSuite: args.test_suite });
+ if (!ev.ok) return { response: result(id, { content: [{ type: 'text', text: ev.message }], isError: true }) };
+ const run = await bench.runEval({ evalId: ev.eval.id, offers: args.offers, owner });
+ return { response: result(id, { content: [{ type: 'text', text: `Eval ${ev.eval.id}, run ${run.id} (${run.status}).` }], structuredContent: { eval: ev.eval, run }, isError: false }) };
+ }
+ default:
+ return { response: error(id, -32602, 'Unknown tool') };
+ }
+}
+
+async function handleMessage(msg, req, ctx) {
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return { response: error(null, -32600, 'Invalid Request') };
+ if (msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') return { response: error(null, -32600, 'Invalid Request') };
+ const hasId = Object.prototype.hasOwnProperty.call(msg, 'id');
+ if (hasId && !validId(msg.id)) return { response: error(null, -32600, 'Invalid Request') };
+ if (!hasId || msg.method.startsWith('notifications/')) return { notification: true };
+ const id = msg.id;
+ const params = msg.params && typeof msg.params === 'object' && !Array.isArray(msg.params) ? msg.params : {};
+ switch (msg.method) {
+ case 'initialize': {
+ const requested = params.protocolVersion;
+ const protocolVersion = SUPPORTED_VERSIONS.has(requested) ? requested : PROTOCOL_VERSION;
+ return { response: result(id, { protocolVersion, capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'openvibe-bench', title: 'OpenVibe Bench', version: pkg.version }, instructions: INSTRUCTIONS }) };
+ }
+ case 'ping':
+ return { response: result(id, {}) };
+ case 'tools/list':
+ return { response: result(id, { tools: TOOLS }) };
+ case 'tools/call':
+ return await callTool(id, params, req, ctx);
+ default:
+ return { response: error(id, -32601, 'Method not found') };
+ }
+}
+
+function mcpRouter(ctx) {
+ const router = express.Router();
+ const json = express.json({ limit: BODY_LIMIT, type: ['application/json', 'application/*+json'], strict: false });
+
+ router.use('/mcp', (_req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); next(); });
+ router.options('/mcp', (_req, res) => {
+ res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Protocol-Version');
+ res.setHeader('Access-Control-Max-Age', '600');
+ res.status(204).end();
+ });
+
+ router.post('/mcp', json, ctx.principal.middleware, async (req, res) => {
+ const version = req.get('mcp-protocol-version');
+ if (version && !SUPPORTED_VERSIONS.has(version)) {
+ const body = req.body;
+ const id = body && !Array.isArray(body) && validId(body.id) ? body.id : null;
+ return res.status(400).json(error(id, -32600, 'Unsupported protocol version'));
+ }
+ const offered = req.body;
+ if (!offered || typeof offered !== 'object') return res.status(200).json(error(null, -32600, 'Invalid Request'));
+ const batch = Array.isArray(offered);
+ if (batch && offered.length === 0) return res.status(200).json(error(null, -32600, 'Invalid Request'));
+ const messages = batch ? offered : [offered];
+ const responses = [];
+ for (const message of messages) {
+ const out = await handleMessage(message, req, ctx);
+ if (out.response) responses.push(out.response);
+ }
+ if (!responses.length) return res.status(202).end();
+ return res.status(200).json(batch ? responses : responses[0]);
+ });
+
+ router.all('/mcp', (_req, res) => {
+ res.setHeader('Allow', 'POST');
+ res.status(405).json(error(null, -32600, 'Method Not Allowed'));
+ });
+
+ // eslint-disable-next-line no-unused-vars
+ router.use((err, req, res, next) => {
+ if (err && err.type === 'entity.parse.failed') return res.status(400).json(error(null, -32700, 'Parse error'));
+ if (err && err.type === 'entity.too.large') return res.status(413).json(error(null, -32600, 'Request body too large'));
+ return next(err);
+ });
+
+ return router;
+}
+
+module.exports = { mcpRouter, TOOLS, INSTRUCTIONS, PROTOCOL_VERSION, SUPPORTED_VERSIONS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+'use strict';
+
+/**
+ * Truthful readiness for GET /api/ready (openvibe-shared/ready).
+ */
+const { jwksStatus } = require('openvibe-sdk/auth');
+const { createReadiness } = require('openvibe-shared/ready');
+
+function createServiceReadiness({ s, config, valkey = null, release = null }) {
+ return createReadiness({
+ service: 'bench',
+ release,
+ checks: [
+ {
+ name: 'db', required: true,
+ check: async () => {
+ const r = await s.db.ready();
+ return r.ok ? { ok: true, detail: r.detail } : r.error;
+ },
+ },
+ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-caller limits count in this process only' }) },
+ {
+ name: 'network_jwks', required: false,
+ check: () => {
+ const statuses = jwksStatus();
+ if (!statuses.length) return 'no JWKS clients (misconfigured)';
+ const first = statuses[0];
+ if (!first.ready) return 'Network signing key not loaded yet: sign-in and the API are unavailable';
+ return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };
+ },
+ },
+ { name: 'oauth_client', required: false, check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete') },
+ ],
+ });
+}
+
+module.exports = { createServiceReadiness };
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+
+/**
+ * Bench's rating maths (plan 2026-10-28 §4.12). Simple, deterministic and correct enough to drop a full
+ * Bradley–Terry library in behind the same interface later:
+ *
+ * eloUpdate(ra, rb, outcome, k) one Elo exchange, returns { a, b }
+ * bradleyTerry(games, { bootstrap, seed }) refit ratings with bootstrap 95% intervals
+ * wilson(successes, n) Wilson 95% lower/upper on a success rate
+ * pareto(rows, { x, y }) non-dominated frontier over quality (x↑) vs cost (y↓)
+ * costPerSolved(entries) total spend / solved count
+ *
+ * The bootstrap uses a small seedable LCG, so the same games and seed always give the same intervals.
+ */
+
+const BASE_ELO = 1000;
+const K_DEFAULT = 32;
+
+function eloUpdate(ra, rb, outcome, k = K_DEFAULT) {
+ const a = Number(ra);
+ const b = Number(rb);
+ const K = Number(k) || K_DEFAULT;
+ let sa;
+ if (outcome === 'left' || outcome === 'win' || outcome === 1) sa = 1;
+ else if (outcome === 'right' || outcome === 'loss' || outcome === 0) sa = 0;
+ else if (outcome === 'tie' || outcome === 0.5) sa = 0.5;
+ else if (outcome === 'both_bad') return { a, b };
+ else return { a, b };
+ const ea = 1 / (1 + 10 ** ((b - a) / 400));
+ const eb = 1 - ea;
+ return {
+ a: Math.round((a + K * (sa - ea)) * 100) / 100,
+ b: Math.round((b + K * ((1 - sa) - eb)) * 100) / 100,
+ };
+}
+
+/**
+ * Non-dominated sort: maximize x (quality), minimize y (cost). A row is on the frontier when no other row has
+ * x >= its x and y <= its y with at least one strict. Rows are returned with `frontier: true|false`.
+ */
+function pareto(rows, { x = 'x', y = 'y' } = {}) {
+ const list = Array.isArray(rows) ? rows.map((r) => ({ ...r })) : [];
+ const out = list.map((r) => ({ ...r, frontier: true }));
+ for (let i = 0; i < out.length; i++) {
+ for (let j = 0; j < out.length; j++) {
+ if (i === j) continue;
+ const a = out[i];
+ const b = out[j];
+ const betterX = Number(b[x] ?? -Infinity) >= Number(a[x] ?? -Infinity);
+ const betterY = Number(b[y] ?? Infinity) <= Number(a[y] ?? Infinity);
+ const strict = Number(b[x] ?? -Infinity) > Number(a[x] ?? -Infinity) || Number(b[y] ?? Infinity) < Number(a[y] ?? Infinity);
+ if (betterX && betterY && strict) { a.frontier = false; break; }
+ }
+ }
+ return out;
+}
+
+/**
+ * Expected cost per solved entry. `entries` are objects with a numeric `cost_usd` and a truthy `solved` flag
+ * (or `outcome: 'pass'` / a positive `score`). Returns null when nothing is solved.
+ */
+function costPerSolved(entries) {
+ const list = Array.isArray(entries) ? entries : [];
+ let cost = 0;
+ let solved = 0;
+ for (const e of list) {
+ const isSolved = Boolean(e && (e.solved || e.outcome === 'pass' || (Number(e.score) || 0) > 0));
+ if (!isSolved) continue;
+ cost += Number(e.cost_usd) || 0;
+ solved += 1;
+ }
+ return solved ? Math.round((cost / solved) * 1e10) / 1e10 : null;
+}
+
+// Bradley–Terry with bootstrap intervals, voter weights, Wilson and percentiles: the tested library (lib.js, BENCH-2).
+const lib = require('./lib');
+
+module.exports = { eloUpdate, bradleyTerry: lib.bradleyTerry, voterWeights: lib.voterWeights, wilson: lib.wilson, percentile: lib.percentile, pareto, costPerSolved, BASE_ELO, K_DEFAULT };
new file mode 100644
new file mode 100644
@@ -0,0 +1,174 @@
@@ -0,0 +1,174 @@
+'use strict';
+
+const ELO_LOG_SCALE = 400 / Math.log(10);
+const PRIOR = 0.1; // Half a win and half a loss against a unit-strength opponent.
+
+function eloUpdate(ra, rb, outcome, k = 32) {
+ if (outcome === 'both_bad') return [ra, rb];
+ if (!['a', 'b', 'tie'].includes(outcome)) throw new RangeError('invalid outcome');
+ const score = outcome === 'a' ? 1 : outcome === 'b' ? 0 : 0.5;
+ const expected = 1 / (1 + 10 ** ((rb - ra) / 400));
+ const change = k * (score - expected);
+ return [ra + change, rb - change];
+}
+
+function percentile(values, p) {
+ if (!values.length) return null;
+ if (!Number.isFinite(p) || p < 0 || p > 100) throw new RangeError('percentile must be in [0, 100]');
+ // Fractions are accepted as well: .5 is the median, .95 is p95.
+ const fraction = p <= 1 ? p : p / 100;
+ const sorted = [...values].sort((a, b) => a - b);
+ const position = (sorted.length - 1) * fraction;
+ const lower = Math.floor(position);
+ const upper = Math.ceil(position);
+ return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
+}
+
+function gameWeight(game, weights) {
+ if (weights == null || game.voter == null) return 1;
+ const value = typeof weights === 'function' ? weights(game.voter, game)
+ : weights instanceof Map ? weights.get(game.voter) : weights[game.voter];
+ if (value == null) return 1;
+ if (!Number.isFinite(value) || value < 0) throw new RangeError('weights must be finite and nonnegative');
+ return value;
+}
+
+function fitStrengths(games, subjects, weights, iterations) {
+ const index = new Map(subjects.map((subject, i) => [subject, i]));
+ const wins = Array(subjects.length).fill(PRIOR / 2);
+ const pairs = new Map();
+ for (const game of games) {
+ if (game.outcome === 'both_bad' || game.a === game.b) continue;
+ const weight = gameWeight(game, weights);
+ if (weight === 0) continue;
+ const a = index.get(game.a);
+ const b = index.get(game.b);
+ const key = a < b ? `${a}:${b}` : `${b}:${a}`;
+ let pair = pairs.get(key);
+ if (!pair) {
+ pair = { a: Math.min(a, b), b: Math.max(a, b), n: 0 };
+ pairs.set(key, pair);
+ }
+ pair.n += weight;
+ if (game.outcome === 'a') wins[a] += weight;
+ else if (game.outcome === 'b') wins[b] += weight;
+ else if (game.outcome === 'tie') {
+ wins[a] += weight / 2;
+ wins[b] += weight / 2;
+ } else throw new RangeError('invalid outcome');
+ }
+
+ let strength = Array(subjects.length).fill(1);
+ for (let iteration = 0; iteration < iterations; iteration++) {
+ const denominator = strength.map(value => PRIOR / (value + 1));
+ for (const pair of pairs.values()) {
+ const term = pair.n / (strength[pair.a] + strength[pair.b]);
+ denominator[pair.a] += term;
+ denominator[pair.b] += term;
+ }
+ const next = strength.map((_, i) => wins[i] / denominator[i]);
+ const change = Math.max(0, ...next.map((value, i) => Math.abs(Math.log(value / strength[i]))));
+ strength = next;
+ if (change < 1e-10) break;
+ }
+ return strength;
+}
+
+function ratingsFor(games, subjects, weights, iterations, anchor) {
+ const strength = fitStrengths(games, subjects, weights, iterations);
+ const logs = strength.map(Math.log);
+ const mean = logs.reduce((sum, value) => sum + value, 0) / logs.length;
+ return logs.map(value => anchor + ELO_LOG_SCALE * (value - mean));
+}
+
+function seededRandom(seed) {
+ let state = seed >>> 0;
+ return () => {
+ state = (state + 0x6D2B79F5) >>> 0;
+ let value = state;
+ value = Math.imul(value ^ (value >>> 15), value | 1);
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+function bradleyTerry(games, { bootstrap = 200, seed = 1, anchor = 1000, iterations = 200, weights } = {}) {
+ const active = games.filter(game => game.outcome !== 'both_bad');
+ const subjects = [...new Set(active.flatMap(game => [game.a, game.b]))].sort();
+ if (!subjects.length) return [];
+ const stats = new Map(subjects.map(subject => [subject, { games: 0, wins: 0, losses: 0, ties: 0 }]));
+ for (const game of active) {
+ if (!['a', 'b', 'tie'].includes(game.outcome)) throw new RangeError('invalid outcome');
+ if (game.a === game.b) throw new RangeError('a game requires two different subjects');
+ const a = stats.get(game.a);
+ const b = stats.get(game.b);
+ a.games++;
+ b.games++;
+ if (game.outcome === 'tie') { a.ties++; b.ties++; }
+ else if (game.outcome === 'a') { a.wins++; b.losses++; }
+ else { b.wins++; a.losses++; }
+ }
+ const ratings = ratingsFor(active, subjects, weights, iterations, anchor);
+ const samples = subjects.map(() => []);
+ if (bootstrap > 0 && active.length) {
+ const random = seededRandom(seed);
+ for (let i = 0; i < bootstrap; i++) {
+ const resampled = Array.from({ length: active.length }, () => active[Math.floor(random() * active.length)]);
+ const draw = ratingsFor(resampled, subjects, weights, iterations, anchor);
+ draw.forEach((rating, j) => samples[j].push(rating));
+ }
+ }
+ return subjects.map((subject, i) => ({
+ subject,
+ rating: ratings[i],
+ ci_lower: samples[i].length ? percentile(samples[i], 2.5) : ratings[i],
+ ci_upper: samples[i].length ? percentile(samples[i], 97.5) : ratings[i],
+ ...stats.get(subject),
+ provisional: stats.get(subject).games < 10,
+ })).sort((a, b) => b.rating - a.rating || String(a.subject).localeCompare(String(b.subject)));
+}
+
+function voterWeights(games, fit, { burnIn = 20, floor = 0.25 } = {}) {
+ const ratings = new Map(fit.map(row => [row.subject, row.rating]));
+ const voters = new Map();
+ for (const game of games) {
+ if (game.voter == null) continue;
+ if (!voters.has(game.voter)) voters.set(game.voter, { count: 0, disagreement: 0 });
+ if (game.outcome === 'both_bad' || !ratings.has(game.a) || !ratings.has(game.b)) continue;
+ const record = voters.get(game.voter);
+ const expected = Math.sign(ratings.get(game.a) - ratings.get(game.b));
+ const observed = game.outcome === 'a' ? 1 : game.outcome === 'b' ? -1 : 0;
+ record.count++;
+ record.disagreement += expected === observed ? 0 : expected === 0 || observed === 0 ? 0.5 : 1;
+ }
+ return new Map([...voters].map(([voter, record]) => [voter,
+ record.count < burnIn ? 1 : Math.max(floor, 1 - record.disagreement / record.count)]));
+}
+
+function wilson(successes, n, z = 1.96) {
+ if (n === 0) return { p: 0, lower: 0, upper: 0 };
+ const p = successes / n;
+ const z2 = z * z;
+ const denominator = 1 + z2 / n;
+ const center = (p + z2 / (2 * n)) / denominator;
+ const radius = z * Math.sqrt((p * (1 - p) + z2 / (4 * n)) / n) / denominator;
+ return { p, lower: center - radius, upper: center + radius };
+}
+
+function pareto(rows, { x, y, lowerX = true, higherY = true }) {
+ return rows.map((row, i) => ({ ...row, frontier: !rows.some((other, j) => {
+ if (i === j) return false;
+ const xBetter = lowerX ? other[x] <= row[x] : other[x] >= row[x];
+ const yBetter = higherY ? other[y] >= row[y] : other[y] <= row[y];
+ const strictlyBetter = other[x] !== row[x] || other[y] !== row[y];
+ return xBetter && yBetter && strictlyBetter;
+ }) }));
+}
+
+function costPerSolved(entries) {
+ const cost_usd = entries.reduce((sum, entry) => sum + entry.cost_usd, 0);
+ const solved = entries.reduce((sum, entry) => sum + Number(entry.solved), 0);
+ return { cost_usd, solved, per_solved: solved ? cost_usd / solved : null };
+}
+
+module.exports = { eloUpdate, bradleyTerry, voterWeights, wilson, pareto, costPerSolved, percentile };
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+
+/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }
+const raw = (s) => new Raw(s);
+function html(strings, ...values) {
+ let out = strings[0];
+ values.forEach((v, i) => { out += render(v) + strings[i + 1]; });
+ return raw(out);
+}
+function render(v) {
+ if (v == null || v === false) return '';
+ if (v instanceof Raw) return v.s;
+ if (Array.isArray(v)) return v.map(render).join('');
+ return esc(v);
+}
+
+const code = (s) => html`<code>${s}</code>`;
+const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
+
+function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {
+ if (!rows.length) return html`<p class="muted">${empty}</p>`;
+ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;
+}
+
+const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;
+const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
+
+module.exports = { esc, html, raw, Raw, render, code, time, table, notice, badge };
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+'use strict';
+
+/**
+ * Page shell. Every page is server-rendered through openvibe-shared/shell and complete without JavaScript.
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const appIcon = require('openvibe-shared/app-icon');
+const shell = require('openvibe-shared/shell');
+const rooms = require('openvibe-shared/rooms');
+const ovServe = require('openvibe-shared/serve');
+const cache = require('openvibe-shared/cache-policy');
+const { render, esc } = require('./html');
+
+const NETWORK_URL = 'https://openvibe.network';
+const SITE_NAME = 'OpenVibe.Bench';
+const TAGLINE = 'One AI benchmark and comparison engine for every room.';
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
+const NAV = [
+ { label: 'Home', href: '/' },
+ { label: 'Docs', href: '/docs' },
+];
+
+const hashes = new Map();
+function assetVersion(rel) {
+ if (hashes.has(rel)) return hashes.get(rel);
+ let v = 'dev';
+ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
+ hashes.set(rel, v);
+ return v;
+}
+const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
+
+let RELEASE = 'dev';
+function setRelease(id) { if (id) RELEASE = String(id); }
+
+function renderPage(o) {
+ const viewer = o.viewer || { kind: 'anonymous' };
+ const signedIn = viewer.kind === 'user';
+ const pathNow = o.path || '/';
+ const loginNext = encodeURIComponent(pathNow);
+ const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
+ const nav = {
+ service: 'bench',
+ apiBase: NETWORK_URL,
+ links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
+ history: { type: 'page', title: o.title || SITE_NAME },
+ silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
+ sessionUrl: '/auth/me',
+ loginUrl: '/auth/login?next={path}',
+ logoutUrl: '/auth/logout?next={path}',
+ notificationsRealtime: true,
+ };
+ const footer = { service: 'bench', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };
+ const account = signedIn
+ ? `Signed in as <strong>${esc(viewer.displayName || viewer.username || 'you')}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
+ : `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
+ const scripts = (o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`);
+ return shell.page({
+ name: SITE_NAME,
+ lang: 'en',
+ title: o.title || `${SITE_NAME}: ${TAGLINE}`,
+ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',
+ siteName: SITE_NAME,
+ description: o.description || `${SITE_NAME} — ${TAGLINE}`,
+ canonical,
+ robots: o.index ? 'index, follow' : 'noindex, nofollow',
+ jsonLd: (o.jsonLd || []).filter(Boolean),
+ home: '/',
+ navLinks: NAV,
+ navbar: nav,
+ footer,
+ head: `<meta name="referrer" content="strict-origin-when-cross-origin">
+${appIcon.headTags({ site: 'bench' })}
+<link rel="stylesheet" href="${ovServe.url('kit.css')}">
+<link rel="stylesheet" href="${ovServe.url('room.css')}">
+<link rel="stylesheet" href="${asset('css/bench.css')}">
+<script src="${asset('js/bench.js')}" defer></script>
+${render(scripts)}
+<meta name="ov-boost" content="bench@${esc(RELEASE)}">
+<script src="${require('openvibe-shared/serve').url('boost.js')}" data-main="#main" defer></script>`,
+ body: `<a class="skip" href="#main">Skip to content</a>
+<div id="navbar-mount"></div>
+<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>
+<main id="main" class="bm ov-room" style="${rooms.style('bench')}">
+${render(o.body)}
+</main>
+<script>
+window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
+document.addEventListener('DOMContentLoaded', function () {
+ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }
+});
+</script>`,
+ });
+}
+
+function send(res, status, o) {
+ const personal = o.viewer && o.viewer.kind === 'user';
+ if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));
+ res.set('Vary', 'Cookie');
+ res.status(status).type('html').send(renderPage(o));
+}
+
+module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, TAGLINE, NETWORK_URL, NAV };
new file mode 100644
new file mode 100644
@@ -0,0 +1,640 @@
@@ -0,0 +1,640 @@
+'use strict';
+
+/**
+ * Bench's data access: kinds, tasks, rounds, entries, judgements, ratings, boards, the open dataset and private
+ * evals. A judgement and the rating changes (plus the outbox rows) commit in one transaction, so a crash can
+ * never leave a vote without its Elo update. One vote per person per round; a creator cannot vote on their own
+ * round; identities stay hidden until a caller has voted (or started the round).
+ */
+const crypto = require('crypto');
+const { ids } = require('openvibe-contracts');
+const { KINDS, JUDGES, WINNERS, kindById } = require('./kinds');
+const ratingLib = require('./rating');
+
+const BASE_ELO = ratingLib.BASE_ELO;
+const K = 32;
+const CATEGORY_DEFAULT = 'all';
+
+const num = (v, d = 0) => (v == null || v === '' || Number.isNaN(Number(v)) ? d : Number(v));
+const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
+const refusal = (status, code, message) => ({ ok: false, status, code, message });
+const sha256 = (s) => crypto.createHash('sha256').update(String(s), 'utf8').digest('hex');
+
+class BenchError extends Error {
+ constructor(status, code, detail) {
+ super(detail);
+ this.name = 'BenchError';
+ this.status = status;
+ this.code = code;
+ this.detail = detail;
+ }
+}
+
+function wireKind(row) {
+ if (!row) return null;
+ let schema = row.schema;
+ let metrics = row.metrics;
+ if (typeof schema === 'string') { try { schema = JSON.parse(schema); } catch { schema = {}; } }
+ if (typeof metrics === 'string') { try { metrics = JSON.parse(metrics); } catch { metrics = []; } }
+ return { id: row.id, room: row.room, entry: row.entry, judge: row.judge, name: row.name, schema, metrics: Array.isArray(metrics) ? metrics : [], created_at: iso(row.created_at) };
+}
+
+function wireTask(row) {
+ if (!row) return null;
+ return { id: row.id, kind_id: row.kind_id, prompt: row.prompt, task_class: row.task_class == null ? null : row.task_class, public: Boolean(row.public), created_by: row.created_by, created_at: iso(row.created_at) };
+}
+
+function wireRound(row) {
+ if (!row) return null;
+ let offers = row.offers;
+ let params = row.params;
+ if (typeof offers === 'string') { try { offers = JSON.parse(offers); } catch { offers = []; } }
+ if (typeof params === 'string') { try { params = JSON.parse(params); } catch { params = {}; } }
+ return {
+ id: row.id, task_id: row.task_id, kind_id: row.kind_id,
+ offers: Array.isArray(offers) ? offers : [],
+ status: row.status, seed: num(row.seed), params,
+ pool_fallback: Boolean(row.pool_fallback),
+ receipt: row.receipt == null ? null : row.receipt,
+ created_by: row.created_by, created_at: iso(row.created_at), finished_at: iso(row.finished_at),
+ };
+}
+
+function wireEntry(row, { blind = false } = {}) {
+ if (!row) return null;
+ let payload = row.payload;
+ let route = row.route;
+ let uiCheck = row.ui_check;
+ if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch { payload = {}; } }
+ if (typeof route === 'string') { try { route = JSON.parse(route); } catch { route = {}; } }
+ if (typeof uiCheck === 'string') { try { uiCheck = JSON.parse(uiCheck); } catch { uiCheck = null; } }
+ const base = {
+ id: row.id,
+ round_id: row.round_id,
+ payload,
+ error: row.error || null,
+ ui_check: uiCheck == null ? null : uiCheck,
+ run_id: row.run_id || null,
+ created_at: iso(row.created_at),
+ };
+ if (blind) return base;
+ return { ...base, provider: row.provider, model: row.model, route, cost_usd: num(row.cost_usd), ms: num(row.ms), tokens: num(row.tokens) };
+}
+
+function wireJudgement(row, { withSubject = false } = {}) {
+ if (!row) return null;
+ let pair = row.pair;
+ if (typeof pair === 'string') { try { pair = JSON.parse(pair); } catch { pair = null; } }
+ const out = {
+ id: row.id, round_id: row.round_id, kind_id: row.kind_id,
+ judge: row.judge, judge_name: row.judge_name || null, judge_version: row.judge_version || null,
+ method: row.method, pair, outcome: row.outcome || null, score: row.score == null ? null : num(row.score), at: iso(row.at),
+ };
+ if (withSubject) out.subject = row.subject || null;
+ return out;
+}
+
+function wireRating(row) {
+ if (!row) return null;
+ return {
+ kind_id: row.kind_id, category: row.category, subject: row.subject,
+ elo: num(row.elo, BASE_ELO), games: num(row.games), wins: num(row.wins), losses: num(row.losses), ties: num(row.ties),
+ win_rate: num(row.win_rate), ci_lower: row.ci_lower == null ? null : num(row.ci_lower),
+ ci_upper: row.ci_upper == null ? null : num(row.ci_upper),
+ median_cost_usd: row.median_cost_usd == null ? null : num(row.median_cost_usd),
+ median_ms: row.median_ms == null ? null : num(row.median_ms),
+ };
+}
+
+function wireEval(row) {
+ if (!row) return null;
+ let suite = row.test_suite;
+ if (typeof suite === 'string') { try { suite = JSON.parse(suite); } catch { suite = []; } }
+ return { id: row.id, owner: row.owner, kind_id: row.kind_id, name: row.name, test_suite: Array.isArray(suite) ? suite : [], created_at: iso(row.created_at) };
+}
+
+function wireEvalRun(row) {
+ if (!row) return null;
+ let offers = row.offers;
+ let results = row.results;
+ if (typeof offers === 'string') { try { offers = JSON.parse(offers); } catch { offers = []; } }
+ if (typeof results === 'string') { try { results = JSON.parse(results); } catch { results = null; } }
+ return { id: row.id, eval_id: row.eval_id, offers: Array.isArray(offers) ? offers : [], status: row.status, results, created_at: iso(row.created_at), finished_at: iso(row.finished_at) };
+}
+
+function shuffle(list, random = Math.random) {
+ const a = list.slice();
+ for (let i = a.length - 1; i > 0; i--) {
+ const j = Math.floor(random() * (i + 1));
+ [a[i], a[j]] = [a[j], a[i]];
+ }
+ return a;
+}
+
+function eloDelta(p) {
+ if (!(p > 0)) return -400;
+ if (p >= 1) return 400;
+ return 400 * Math.log10(p / (1 - p));
+}
+
+/** Wilson bounds on win rate, mapped to an Elo band around the live rating. */
+function ratingCi(elo, wins, losses, ties) {
+ const games = wins + losses + ties;
+ if (!games) return { ci_lower: null, ci_upper: null };
+ const p = (wins + ties * 0.5) / games;
+ const w = ratingLib.wilson(wins + ties * 0.5, games);
+ const delta = eloDelta(p);
+ return {
+ ci_lower: Math.round((elo + (eloDelta(w.lower) - delta)) * 100) / 100,
+ ci_upper: Math.round((elo + (eloDelta(w.upper) - delta)) * 100) / 100,
+ };
+}
+
+function createStore(s, { log = console, random = Math.random, config = null } = {}) {
+ const db = s.db;
+
+ // ── Outbox (in the same ambient transaction as the change that caused it) ──
+ async function insertOutbox(topic, payload) {
+ const eventId = ids.newId('event', s.now());
+ await db.exec(
+ `INSERT INTO bench_outbox (event_id, topic, payload, created_at, next_attempt_at)
+ VALUES ($1, $2, $3::jsonb, $4::timestamptz, $4::timestamptz)`,
+ [eventId, topic, JSON.stringify(payload), s.iso()],
+ );
+ return eventId;
+ }
+
+ // ── Kinds ───────────────────────────────────────────────
+ async function seedKinds() {
+ for (const k of KINDS) {
+ await db.exec(
+ `INSERT INTO bench_kinds (id, room, entry, judge, name, schema, metrics, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, now())
+ ON CONFLICT (id) DO UPDATE SET room = EXCLUDED.room, entry = EXCLUDED.entry, judge = EXCLUDED.judge,
+ name = EXCLUDED.name, schema = EXCLUDED.schema, metrics = EXCLUDED.metrics`,
+ [k.id, k.room, k.entry, k.judge, k.name, JSON.stringify(k.schema), JSON.stringify(k.metrics)],
+ );
+ }
+ }
+ async function listKinds() {
+ return (await db.many('SELECT * FROM bench_kinds ORDER BY id')).map(wireKind);
+ }
+ async function getKind(id) {
+ return wireKind(await db.maybe('SELECT * FROM bench_kinds WHERE id = $1', [String(id || '')]));
+ }
+
+ // ── Tasks ───────────────────────────────────────────────
+ async function createTask({ kindId, prompt, taskClass = null, public: isPublic = false, subject }) {
+ const kind = await getKind(kindId);
+ if (!kind) return refusal(422, 'bench.kind_not_found', `No such kind: ${kindId}`);
+ const text = String(prompt == null ? '' : prompt).replace(/\s+/g, ' ').trim();
+ if (!text) return refusal(422, 'bench.prompt_required', 'A task needs a prompt or spec.');
+ if (text.length > 4000) return refusal(422, 'bench.prompt_too_long', 'The prompt must be at most 4000 characters.');
+ const id = s.newId('btk');
+ const row = await db.maybe(
+ `INSERT INTO bench_tasks (id, kind_id, prompt, task_class, public, created_by, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7::timestamptz) RETURNING *`,
+ [id, kind.id, text, taskClass, Boolean(isPublic), subject, s.iso()],
+ );
+ return { ok: true, task: wireTask(row) };
+ }
+ async function getTask(id) {
+ return wireTask(await db.maybe('SELECT * FROM bench_tasks WHERE id = $1', [String(id || '')]));
+ }
+
+ // ── Rounds ──────────────────────────────────────────────
+ const dayStart = (at = s.now()) => {
+ const d = new Date(at);
+ d.setUTCHours(0, 0, 0, 0);
+ return d.toISOString();
+ };
+
+ async function roundCounts(subject) {
+ const start = dayStart();
+ const perDay = config ? config.bench.roundsPerDay : 3;
+ const daily = config ? config.bench.roundsDaily : 20;
+ const used = num(await db.value('SELECT COUNT(*)::int AS n FROM bench_rounds WHERE created_by = $1 AND created_at >= $2::timestamptz', [subject, start]));
+ const siteUsed = num(await db.value('SELECT COUNT(*)::int AS n FROM bench_rounds WHERE created_at >= $1::timestamptz', [start]));
+ return {
+ used,
+ remaining: Math.max(0, perDay - used),
+ per_day: perDay,
+ site_used: siteUsed,
+ site_daily: daily,
+ site_remaining: Math.max(0, daily - siteUsed),
+ };
+ }
+
+ async function createRound({ taskId, offers = null, seed = 0, subject }) {
+ const task = await getTask(taskId);
+ if (!task) return refusal(404, 'bench.task_not_found', 'No such task.');
+ const kind = await getKind(task.kind_id);
+ if (!kind) return refusal(422, 'bench.kind_not_found', 'The task\'s kind is gone.');
+ const off = Array.isArray(offers) ? offers.slice(0, 3).map((o) => ({ provider: String(o.provider || ''), model: o.model == null ? null : String(o.model) })) : [];
+ if (off.some((o) => !o.provider)) return refusal(422, 'bench.invalid_offers', 'Every offer needs a provider.');
+ const perDay = config ? config.bench.roundsPerDay : 3;
+ const daily = config ? config.bench.roundsDaily : 20;
+ const start = dayStart();
+ const id = s.newId('brd');
+ const row = await db.maybe(
+ `INSERT INTO bench_rounds (id, task_id, kind_id, offers, status, seed, params, pool_fallback, created_by, created_at)
+ SELECT $1, $2, $3, $4::jsonb, 'queued', $5, '{}'::jsonb, $6, $7, $8::timestamptz
+ WHERE (SELECT COUNT(*) FROM bench_rounds WHERE created_by = $7 AND created_at >= $9::timestamptz) < $10
+ AND (SELECT COUNT(*) FROM bench_rounds WHERE created_at >= $9::timestamptz) < $11
+ RETURNING *`,
+ [id, task.id, kind.id, JSON.stringify(off), Math.max(0, Number(seed) || 0), off.length < 3, subject, s.iso(), start, perDay, daily],
+ );
+ if (!row) {
+ const c = await roundCounts(subject);
+ if (c.used >= perDay) throw new BenchError(429, 'bench.limit_person', `You can start ${perDay} round${perDay === 1 ? '' : 's'} a day.`);
+ throw new BenchError(429, 'bench.limit_site', `Bench has reached its ${daily} round${daily === 1 ? '' : 's'} for today.`);
+ }
+ return { ok: true, round: await getRound(id) };
+ }
+
+ async function getRound(id, { viewerSubject = null } = {}) {
+ const r = await db.maybe('SELECT * FROM bench_rounds WHERE id = $1', [String(id || '')]);
+ if (!r) return null;
+ const entryRows = await db.many('SELECT * FROM bench_entries WHERE round_id = $1 ORDER BY created_at, id', [r.id]);
+ const revealed = viewerSubject != null && (r.created_by === viewerSubject
+ || await db.value('SELECT COUNT(*)::int AS n FROM bench_judgements WHERE round_id = $1 AND subject = $2', [r.id, viewerSubject]) > 0);
+ const votes = num(await db.value('SELECT COUNT(*)::int AS n FROM bench_judgements WHERE round_id = $1', [r.id]));
+ const entries = entryRows.map((e) => wireEntry(e, { blind: !revealed }));
+ const out = { ...wireRound(r), entries, votes };
+ if (!revealed) out.blind = true;
+ return out;
+ }
+
+ /** Insert an entry into a round; moves a room-submitted round to ready when it has two usable entries. */
+ async function addEntry({ roundId, provider, model, route, payload, costUsd = 0, ms = 0, tokens = 0, error = null, uiCheck = null, runId = null }) {
+ return await s.tx(async () => {
+ const r = await db.maybe('SELECT * FROM bench_rounds WHERE id = $1 FOR UPDATE', [String(roundId || '')]);
+ if (!r) throw new BenchError(404, 'bench.round_not_found', 'No such Bench round.');
+ const id = s.newId('bte');
+ const row = await db.maybe(
+ `INSERT INTO bench_entries (id, round_id, provider, model, route, payload, cost_usd, ms, tokens, error, ui_check, run_id, created_at)
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11::jsonb, $12, $13::timestamptz) RETURNING *`,
+ [id, r.id, String(provider || 'ai').slice(0, 80), String(model || 'unknown').slice(0, 120), JSON.stringify(route || {}),
+ JSON.stringify(payload || {}), num(costUsd), Math.max(0, num(ms)), Math.max(0, num(tokens)), error || null,
+ uiCheck == null ? null : JSON.stringify(uiCheck), runId || null, s.iso()],
+ );
+ await maybeReady(r.id);
+ return wireEntry(row);
+ });
+ }
+
+ async function maybeReady(roundId) {
+ const usable = num(await db.value('SELECT COUNT(*)::int AS n FROM bench_entries WHERE round_id = $1 AND error IS NULL', [roundId]));
+ if (usable >= 2) {
+ const r = await db.maybe('SELECT * FROM bench_rounds WHERE id = $1 FOR UPDATE', [roundId]);
+ if (r && (r.status === 'queued' || r.status === 'running')) {
+ await finishRound(r.id);
+ }
+ }
+ }
+
+ async function finishRound(roundId) {
+ const r = await db.maybe('SELECT * FROM bench_rounds WHERE id = $1', [roundId]);
+ if (!r) return;
+ const receipt = sha256(JSON.stringify({ id: r.id, task_id: r.task_id, kind_id: r.kind_id, offers: r.offers, seed: r.seed, params: r.params }));
+ await db.exec("UPDATE bench_rounds SET status = 'finished', receipt = $1, finished_at = $2::timestamptz WHERE id = $3",
+ [receipt, s.iso(), r.id]);
+ await insertOutbox('bench.round.finished', {
+ round_id: r.id, kind_id: r.kind_id, task_id: r.task_id, receipt, created_by: r.created_by,
+ });
+ }
+
+ // ── Judgements ──────────────────────────────────────────
+ async function pairwiseGames(kindId, category = CATEGORY_DEFAULT) {
+ const rows = await db.many(
+ `SELECT * FROM bench_judgements
+ WHERE kind_id = $1 AND method IN ('human_pairwise', 'game_outcome') AND pair IS NOT NULL
+ AND ($2::text = 'all' OR outcome IS NOT NULL)
+ ORDER BY at, id`,
+ [kindId, category],
+ );
+ const out = [];
+ for (const j of rows) {
+ const pair = typeof j.pair === 'string' ? JSON.parse(j.pair) : j.pair;
+ if (!pair || !pair.left || !pair.right) continue;
+ if (j.outcome === 'both_bad') continue;
+ out.push({ a: pair.left, b: pair.right, outcome: j.outcome === 'left' ? 1 : j.outcome === 'right' ? 0 : 0.5 });
+ }
+ return out;
+ }
+
+ /** A ready round this person has not judged and did not start, with two entries shuffled and identities hidden. */
+ async function nextJudgement({ kind, subject, random: rng = random } = {}) {
+ const row = await db.maybe(
+ `SELECT r.* FROM bench_rounds r
+ WHERE r.kind_id = $1 AND r.status = 'finished' AND r.created_by <> $2
+ AND (SELECT COUNT(*) FROM bench_entries e WHERE e.round_id = r.id AND e.error IS NULL) >= 2
+ AND NOT EXISTS (SELECT 1 FROM bench_judgements j WHERE j.round_id = r.id AND j.subject = $2)
+ ORDER BY r.created_at, r.id LIMIT 1`,
+ [String(kind || ''), subject],
+ );
+ if (!row) return null;
+ const entries = (await db.many('SELECT * FROM bench_entries WHERE round_id = $1 AND error IS NULL ORDER BY id', [row.id])).map(wireEntry);
+ if (entries.length < 2) return null;
+ const [left, right] = shuffle(entries, rng).slice(0, 2);
+ return {
+ round: { id: row.id, kind_id: row.kind_id, prompt: (await getTask(row.task_id)).prompt },
+ left: { id: left.id, payload: left.payload },
+ right: { id: right.id, payload: right.payload },
+ };
+ }
+
+ async function castJudgement({ roundId, kindId, judge, judgeName = null, judgeVersion = null, pair = null, outcome = null, score = null, subject }) {
+ if (!JUDGES.includes(judge)) throw new BenchError(422, 'bench.invalid_judge', 'Unknown judge.');
+ try {
+ return await s.tx(async () => {
+ const r = await db.maybe('SELECT * FROM bench_rounds WHERE id = $1 FOR UPDATE', [String(roundId || '')]);
+ if (!r) throw new BenchError(404, 'bench.round_not_found', 'No such Bench round.');
+ if (r.status !== 'finished' && r.status !== 'ready') throw new BenchError(409, 'bench.round_not_ready', 'This round is not ready to judge yet.');
+
+ let left = null;
+ let right = null;
+ if (judge === 'human_pairwise' || judge === 'game_outcome') {
+ if (r.created_by === subject) throw new BenchError(403, 'bench.creator_cannot_vote', 'You cannot judge a round you started.');
+ const p = pair && typeof pair === 'object' ? pair : {};
+ if (!p.left || !p.right || p.left === p.right) throw new BenchError(422, 'bench.invalid_pair', 'A pairwise judgement needs two different entries.');
+ const prior = await db.maybe('SELECT 1 AS n FROM bench_judgements WHERE round_id = $1 AND subject = $2', [r.id, subject]);
+ if (prior) throw new BenchError(409, 'bench.already_judged', 'You have already judged this round.');
+ const rows = await db.many('SELECT * FROM bench_entries WHERE round_id = $1 AND id = ANY($2::text[]) AND error IS NULL', [r.id, [p.left, p.right]]);
+ if (rows.length !== 2) throw new BenchError(422, 'bench.invalid_pair', 'Those two entries are not both part of this round.');
+ left = wireEntry(rows.find((o) => o.id === p.left));
+ right = wireEntry(rows.find((o) => o.id === p.right));
+ const allowed = judge === 'game_outcome' ? ['left', 'right', 'draw'] : WINNERS;
+ if (!allowed.includes(outcome)) throw new BenchError(422, 'bench.invalid_outcome', `The outcome must be ${allowed.join(', ')}.`);
+ }
+
+ const id = s.newId('bju');
+ const row = await db.maybe(
+ `INSERT INTO bench_judgements (id, round_id, kind_id, judge, judge_name, judge_version, method, pair, outcome, score, subject, at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12::timestamptz) RETURNING *`,
+ [id, r.id, r.kind_id, judge, judgeName, judgeVersion, judge, pair ? JSON.stringify(pair) : null,
+ outcome || null, score == null ? null : num(score), subject || null, s.iso()],
+ );
+ if (judge === 'human_pairwise' || judge === 'game_outcome') {
+ await applyPairwiseRating(r.id, r.kind_id, left, right, outcome);
+ }
+ await insertOutbox('bench.judgement.recorded', {
+ judgement_id: id, round_id: r.id, kind_id: r.kind_id, judge, method: judge, outcome: outcome || null, score: score == null ? null : num(score),
+ });
+ return { judgement: wireJudgement(row), revealed: left && right ? { left, right } : null };
+ });
+ } catch (err) {
+ if (err && err.code === '23505') throw new BenchError(409, 'bench.already_judged', 'You have already judged this round.');
+ throw err;
+ }
+ }
+
+ async function applyPairwiseRating(roundId, kindId, left, right, outcome) {
+ if (outcome === 'both_bad') return;
+ if (!left || !right || left.model === right.model) return;
+ const models = [left.model, right.model];
+ const now = s.iso();
+ for (const model of models) {
+ await db.exec(
+ `INSERT INTO bench_ratings (kind_id, category, subject, elo, games, wins, losses, ties, win_rate, ci_lower, ci_upper, updated_at)
+ VALUES ($1, $2, $3, $4, 0, 0, 0, 0, 0, NULL, NULL, $5::timestamptz)
+ ON CONFLICT (kind_id, category, subject) DO NOTHING`,
+ [kindId, CATEGORY_DEFAULT, model, BASE_ELO, now],
+ );
+ }
+ const rows = await db.many('SELECT * FROM bench_ratings WHERE kind_id = $1 AND category = $2 AND subject = ANY($3::text[]) ORDER BY subject FOR UPDATE',
+ [kindId, CATEGORY_DEFAULT, models]);
+ const byModel = new Map(rows.map((r) => [r.subject, r]));
+ const a = byModel.get(left.model);
+ const b = byModel.get(right.model);
+ if (!a || !b) return;
+ const eloOutcome = outcome === 'draw' ? 'tie' : outcome;
+ const update = ratingLib.eloUpdate(num(a.elo, BASE_ELO), num(b.elo, BASE_ELO), eloOutcome, K);
+ const sa = outcome === 'left' ? 1 : outcome === 'right' ? 0 : 0.5;
+ const sb = 1 - sa;
+ for (const [row, elo, score, opp] of [[a, update.a, sa, sb], [b, update.b, sb, sa]]) {
+ const games = num(row.games) + 1;
+ const wins = num(row.wins) + (score > opp ? 1 : 0);
+ const losses = num(row.losses) + (score < opp ? 1 : 0);
+ const ties = num(row.ties) + (score === opp ? 1 : 0);
+ const ci = ratingCi(elo, wins, losses, ties);
+ await db.exec(
+ `UPDATE bench_ratings SET elo = $1, games = $2, wins = $3, losses = $4, ties = $5,
+ win_rate = $6, ci_lower = $7, ci_upper = $8, updated_at = $9::timestamptz
+ WHERE kind_id = $10 AND category = $11 AND subject = $12`,
+ [elo, games, wins, losses, ties, games ? Math.round(((wins + ties * 0.5) / games) * 10000) / 10000 : 0,
+ ci.ci_lower, ci.ci_upper, now, kindId, CATEGORY_DEFAULT, row.subject],
+ );
+ }
+ await insertOutbox('bench.rating.changed', { kind_id: kindId, category: CATEGORY_DEFAULT, subjects: models });
+ }
+
+ // ── Boards ──────────────────────────────────────────────
+ async function subjectsFor(kindId) {
+ const rows = await db.many(
+ `SELECT DISTINCT e.model FROM bench_entries e JOIN bench_rounds r ON r.id = e.round_id
+ WHERE r.kind_id = $1 AND e.error IS NULL ORDER BY e.model`,
+ [kindId],
+ );
+ return rows.map((r) => r.model);
+ }
+
+ async function boardElo(kindId) {
+ const subjects = await subjectsFor(kindId);
+ const ratings = await db.many('SELECT * FROM bench_ratings WHERE kind_id = $1 AND category = $2', [kindId, CATEGORY_DEFAULT]);
+ const by = new Map(ratings.map((r) => [r.subject, wireRating(r)]));
+ const rows = subjects.map((subject) => {
+ const r = by.get(subject);
+ const games = r ? r.games : 0;
+ return {
+ kind_id: kindId, category: CATEGORY_DEFAULT, subject,
+ elo: r ? r.elo : BASE_ELO,
+ games, wins: r ? r.wins : 0, losses: r ? r.losses : 0, ties: r ? r.ties : 0,
+ win_rate: r ? r.win_rate : 0,
+ ci_lower: r ? r.ci_lower : null,
+ ci_upper: r ? r.ci_upper : null,
+ provisional: games < 10,
+ };
+ });
+ rows.sort((a, b) => b.elo - a.elo || a.subject.localeCompare(b.subject));
+ return rows;
+ }
+
+ async function costStats(kindId) {
+ const rows = await db.many(
+ `SELECT e.model,
+ COUNT(*)::int AS entries,
+ COALESCE(SUM(e.cost_usd), 0)::numeric AS total_cost,
+ COUNT(*) FILTER (WHERE e.error IS NULL)::int AS usable,
+ percentile_cont(0.5) WITHIN GROUP (ORDER BY e.cost_usd) AS median_cost,
+ percentile_cont(0.5) WITHIN GROUP (ORDER BY e.ms) AS median_ms
+ FROM bench_entries e JOIN bench_rounds r ON r.id = e.round_id
+ WHERE r.kind_id = $1 AND e.error IS NULL
+ GROUP BY e.model`,
+ [kindId],
+ );
+ return new Map(rows.map((r) => [r.model, r]));
+ }
+
+ async function passRate(kindId) {
+ const rows = await db.many(
+ `SELECT e.model, COUNT(*)::int AS trials, COUNT(*) FILTER (WHERE j.outcome = 'pass' OR COALESCE(j.score, 0) > 0)::int AS passed
+ FROM bench_entries e
+ JOIN bench_rounds r ON r.id = e.round_id
+ JOIN bench_judgements j ON j.round_id = r.id
+ WHERE r.kind_id = $1 AND e.error IS NULL
+ GROUP BY e.model`,
+ [kindId],
+ );
+ return new Map(rows.map((r) => [r.model, r]));
+ }
+
+ async function board(kindId, { type = 'elo', category = CATEGORY_DEFAULT, period = 'all' } = {}) {
+ const kind = await getKind(kindId);
+ if (!kind) return null;
+ const cost = await costStats(kindId);
+ const pass = await passRate(kindId);
+ const eloRows = await boardElo(kindId);
+
+ let rows;
+ if (type === 'elo') {
+ rows = eloRows.map((r) => ({ ...r, median_cost_usd: num(cost.get(r.subject)?.median_cost), median_ms: num(cost.get(r.subject)?.median_ms) }));
+ } else if (type === 'pareto') {
+ const subjects = await subjectsFor(kindId);
+ rows = subjects.map((subject) => {
+ const e = eloRows.find((x) => x.subject === subject) || {};
+ const p = pass.get(subject);
+ const quality = p ? (num(p.trials) ? num(p.passed) / num(p.trials) : 0) : (num(e.win_rate) || 0);
+ const medianCost = num(cost.get(subject)?.median_cost);
+ return { subject, quality, win_rate: num(e.win_rate) || 0, pass_rate: p ? (num(p.trials) ? num(p.passed) / num(p.trials) : 0) : 0, cost: medianCost, median_cost_usd: medianCost, median_ms: num(cost.get(subject)?.median_ms), provisional: num(e.games) < 10 };
+ });
+ rows = ratingLib.pareto(rows, { x: 'quality', y: 'cost' });
+ } else if (type === 'cost') {
+ rows = [...cost.values()].map((r) => {
+ const solved = num(pass.get(r.model)?.passed) || num(r.usable) || 0;
+ const totalCost = num(r.total_cost);
+ return {
+ subject: r.model, entries: num(r.entries), usable: num(r.usable),
+ total_cost_usd: Math.round(totalCost * 1e6) / 1e6,
+ solved,
+ cost_per_solved: solved ? Math.round((totalCost / solved) * 1e6) / 1e6 : null,
+ median_cost_usd: num(r.median_cost),
+ median_ms: num(r.median_ms),
+ };
+ });
+ rows.sort((a, b) => (a.cost_per_solved == null ? Infinity : a.cost_per_solved) - (b.cost_per_solved == null ? Infinity : b.cost_per_solved) || a.subject.localeCompare(b.subject));
+ } else {
+ return null;
+ }
+ return { kind_id: kindId, category, period, type, rows };
+ }
+
+ async function modelSummary(model) {
+ const name = String(model || '');
+ const ratings = (await db.many('SELECT * FROM bench_ratings WHERE subject = $1 ORDER BY kind_id, category', [name])).map(wireRating);
+ const entries = (await db.many(
+ `SELECT e.*, r.kind_id FROM bench_entries e JOIN bench_rounds r ON r.id = e.round_id WHERE e.model = $1 ORDER BY e.created_at DESC LIMIT 100`,
+ [name],
+ )).map((e) => ({ ...wireEntry(e), kind_id: e.kind_id }));
+ const cost = num(await db.value('SELECT COALESCE(SUM(cost_usd), 0)::numeric AS n FROM bench_entries WHERE model = $1', [name]));
+ return { model: name, ratings, entries, total_cost_usd: Math.round(cost * 1e6) / 1e6 };
+ }
+
+ // ── Dataset (open, no voter ids) ─────────────────────────
+ async function dataset(kindId) {
+ const kind = await getKind(kindId);
+ if (!kind) return null;
+ const tasks = (await db.many('SELECT * FROM bench_tasks WHERE kind_id = $1 AND public = true ORDER BY created_at, id', [kindId]))
+ .map(wireTask).map(({ created_by, ...rest }) => rest);
+ const rounds = (await db.many("SELECT * FROM bench_rounds WHERE kind_id = $1 AND status IN ('ready', 'finished') ORDER BY created_at, id", [kindId]))
+ .map(wireRound).map(({ created_by, ...rest }) => rest);
+ const roundIds = rounds.map((r) => r.id);
+ let entries = [];
+ let judgements = [];
+ if (roundIds.length) {
+ entries = (await db.many('SELECT * FROM bench_entries WHERE round_id = ANY($1::text[]) ORDER BY created_at, id', [roundIds])).map(wireEntry);
+ judgements = (await db.many('SELECT * FROM bench_judgements WHERE round_id = ANY($1::text[]) ORDER BY at, id', [roundIds])).map((j) => wireJudgement(j));
+ }
+ const ratings = (await db.many('SELECT * FROM bench_ratings WHERE kind_id = $1 ORDER BY subject', [kindId])).map(wireRating);
+ return { kind_id: kindId, tasks, rounds, entries, judgements, ratings };
+ }
+
+ async function datasetJsonl(kindId) {
+ const d = await dataset(kindId);
+ if (!d) return null;
+ const lines = [];
+ for (const t of d.tasks) lines.push(JSON.stringify({ type: 'task', ...t }));
+ for (const r of d.rounds) lines.push(JSON.stringify({ type: 'round', ...r }));
+ for (const e of d.entries) lines.push(JSON.stringify({ type: 'entry', ...e }));
+ for (const j of d.judgements) lines.push(JSON.stringify({ type: 'judgement', ...j }));
+ for (const r of d.ratings) lines.push(JSON.stringify({ type: 'rating', ...r }));
+ return lines.length ? `${lines.join('\n')}\n` : '';
+ }
+
+ // ── Private evals ────────────────────────────────────────
+ async function createEval({ owner, kindId, name, testSuite }) {
+ const kind = await getKind(kindId);
+ if (!kind) return refusal(422, 'bench.kind_not_found', `No such kind: ${kindId}`);
+ const cleanName = String(name == null ? '' : name).replace(/\s+/g, ' ').trim().slice(0, 160);
+ if (!cleanName) return refusal(422, 'bench.eval_name_required', 'An eval needs a name.');
+ const suite = Array.isArray(testSuite) ? testSuite : [];
+ if (!suite.length) return refusal(422, 'bench.eval_suite_required', 'An eval needs a non-empty test_suite.');
+ const id = s.newId('bev');
+ const row = await db.maybe(
+ `INSERT INTO bench_evals (id, owner, kind_id, name, test_suite, created_at)
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6::timestamptz) RETURNING *`,
+ [id, owner, kind.id, cleanName, JSON.stringify(suite), s.iso()],
+ );
+ return { ok: true, eval: wireEval(row) };
+ }
+ async function getEval(id, { owner = null } = {}) {
+ const row = await db.maybe('SELECT * FROM bench_evals WHERE id = $1', [String(id || '')]);
+ if (!row || (owner && row.owner !== owner)) return null;
+ return wireEval(row);
+ }
+
+ async function evalDailyCount(owner) {
+ const start = dayStart();
+ return num(await db.value(
+ `SELECT COUNT(*)::int AS n FROM bench_eval_runs r JOIN bench_evals e ON e.id = r.eval_id
+ WHERE e.owner = $1 AND r.created_at >= $2::timestamptz`,
+ [owner, start],
+ ));
+ }
+
+ async function runEval({ evalId, offers, owner }) {
+ const ev = await getEval(evalId, { owner });
+ if (!ev) throw new BenchError(404, 'bench.eval_not_found', 'No such eval (or it is not yours).');
+ const used = await evalDailyCount(owner);
+ const cap = config ? config.bench.evalsPerDay : 20;
+ if (used >= cap) throw new BenchError(429, 'bench.eval_limit', `You can run ${cap} private evals a day.`);
+ const off = Array.isArray(offers) ? offers.map((o) => ({ provider: String(o.provider || ''), model: o.model == null ? null : String(o.model) })) : [];
+ const id = s.newId('ber');
+ const row = await db.maybe(
+ `INSERT INTO bench_eval_runs (id, eval_id, offers, status, created_at)
+ VALUES ($1, $2, $3::jsonb, 'queued', $4::timestamptz) RETURNING *`,
+ [id, ev.id, JSON.stringify(off), s.iso()],
+ );
+ return wireEvalRun(row);
+ }
+
+ async function evalResults(evalId, { owner = null } = {}) {
+ const ev = await getEval(evalId, { owner });
+ if (!ev) return null;
+ const runs = (await db.many('SELECT * FROM bench_eval_runs WHERE eval_id = $1 ORDER BY created_at DESC', [ev.id])).map(wireEvalRun);
+ return { eval: ev, runs };
+ }
+
+ return {
+ wireKind, wireTask, wireRound, wireEntry, wireJudgement, wireRating, wireEval, wireEvalRun,
+ insertOutbox, seedKinds, listKinds, getKind,
+ createTask, getTask,
+ createRound, getRound, addEntry, maybeReady, finishRound,
+ nextJudgement, castJudgement, pairwiseGames,
+ board, modelSummary, dataset, datasetJsonl,
+ createEval, getEval, runEval, evalResults, evalDailyCount,
+ KINDS, JUDGES, WINNERS, BASE_ELO, CATEGORY_DEFAULT, shuffle,
+ };
+}
+
+module.exports = { createStore, BenchError, KINDS, JUDGES, WINNERS, BASE_ELO, CATEGORY_DEFAULT, shuffle };
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+
+/**
+ * Bench's own origin pages (the main session designs them later): a plain home that says what Bench is, lists the
+ * fifteen kinds and links to boards and docs, and /docs for the API. Ids and classes start with `bm-`.
+ */
+const { esc } = require('../render/html');
+
+function kindRow(k) {
+ return `<li class="bm-kind"><a class="bm-kind-link" href="/api/v1/boards/${esc(k.id)}?type=elo">${esc(k.id)}</a><span class="bm-kind-meta">${esc(k.room)} · ${esc(k.judge)} · ${esc(k.name)}</span></li>`;
+}
+
+function home({ kinds = [], counts = { kinds: 0 } } = {}) {
+ return `<div class="bm-wrap bm-home" id="bm-home">
+<header class="bm-hero"><p class="bm-kicker">OpenVibe.Bench</p><h1>One benchmark engine for every room</h1>
+<p class="bm-lede">Put AI models side by side on real work: a page design, a game, a meme, a code fix, a web task. People vote blind, graders and tests score the rest, and every round is pinned to the exact model it ran, so anyone can run it again.</p>
+<div class="bm-actions"><a class="bm-btn" href="/docs">Read the API</a><a class="bm-btn bm-btn-quiet" href="#bm-kinds">The kinds</a></div></header>
+<ul class="bm-why" aria-label="What Bench promises">
+<li><b>Free and open</b><span>The public arena costs nothing, and every kind's votes and results download as JSONL, without voter names.</span></li>
+<li><b>Cost per solved task</b><span>Boards rank quality against what a solved task really cost, with confidence intervals, not a single score.</span></li>
+<li><b>Every room, one engine</b><span>Design, Games, Meme, Vision, Codes, Surf, Chat and speech share one API, SDK and MCP tools.</span></li>
+<li><b>Your own evals</b><span>Private task suites on your models or keys, free up to a daily cap, never published.</span></li>
+</ul>
+<section class="bm-section" id="bm-kinds" aria-labelledby="bm-kinds-h"><div class="bm-head-row"><h2 id="bm-kinds-h">Kinds</h2><span class="bm-count">${Number(counts.kinds) || kinds.length}</span></div>
+${kinds.length ? `<ul class="bm-kinds">${kinds.map(kindRow).join('')}</ul>` : '<p class="bm-muted">No kinds are seeded yet.</p>'}</section>
+<section class="bm-section" id="bm-boards"><div class="bm-head-row"><h2>Boards</h2></div>
+<p class="bm-fine">Boards are JSON at <code>/api/v1/boards/:kind?type=elo|pareto|cost</code>. Public HTML boards render in OpenVibe.Report; rooms show their own arenas through Bench.</p></section>
+</div>`;
+}
+
+function docs() {
+ const ep = (m, p, d) => `<div><dt><code>${esc(m)} ${esc(p)}</code></dt><dd>${d}</dd></div>`;
+ return `<div class="bm-wrap bm-docs" id="bm-docs">
+<header class="bm-page-h"><p class="bm-kicker">Developers</p><h1>The Bench API</h1>
+<p class="bm-lede">JSON under <code>/api/v1</code>, with <code>application/problem+json</code> errors. A person is the <code>bench_at</code> session cookie or a Network user token as a Bearer; a service sends a client-credentials token with <code>bench.*</code> capabilities.</p></header>
+<section><h2>Read</h2><dl class="bm-eps">
+${ep('GET', '/api/v1/kinds', 'The fifteen benchmark kinds.')}
+${ep('GET', '/api/v1/kinds/:kindId', 'One kind and its entry schema and metrics.')}
+${ep('GET', '/api/v1/tasks/:id', 'One task.')}
+${ep('GET', '/api/v1/rounds/:id', 'One round with entries; entries stay blind until the caller has judged or started the round.')}
+${ep('GET', '/api/v1/boards/:kindId?type=elo|pareto|cost', 'A rendered board.')}
+${ep('GET', '/api/v1/models/:model', 'One model across every kind.')}
+${ep('GET', '/api/v1/datasets/:kindId.jsonl', 'The open dataset (prompts, entries, judgements without voter ids, ratings).')}
+</dl></section>
+<section><h2>Write</h2><dl class="bm-eps">
+${ep('POST', '/api/v1/kinds/:kindId/tasks', '<code>{ prompt, task_class?, public? }</code>. A person or a service with bench.task.create.')}
+${ep('POST', '/api/v1/tasks/:id/rounds', '<code>{ offers?, seed? }</code>. Pins a reproducible round; the job generates entries.')}
+${ep('POST', '/api/v1/rounds/:id/entries', 'A room submits outside entries (a game match, a test-suite run) with a service token carrying bench.entry.create.')}
+${ep('GET', '/api/v1/judgements/next?kind=&judge=', 'The next blind pair for this person.')}
+${ep('POST', '/api/v1/rounds/:id/judgements', '<code>{ judge?, pair?, outcome?, score? }</code>. Human judges are people; automatic/test/game judges are services with bench.judgement.cast.')}
+${ep('POST', '/api/v1/evals', 'Create a private task suite (never in the public dataset).')}
+${ep('POST', '/api/v1/evals/:id/run', 'Run a private eval against chosen offers, under the free daily cap.')}
+${ep('GET', '/api/v1/evals/:id/results', 'A private eval and its runs.')}
+</dl></section>
+<section><h2>MCP</h2><p class="bm-fine"><code>POST /mcp</code> exposes <code>bench.list_kinds</code>, <code>bench.start_round</code>, <code>bench.next_pair</code>, <code>bench.cast_judgement</code>, <code>bench.get_board</code> and <code>bench.run_eval</code>.</p></section>
+</div>`;
+}
+
+function notFound() {
+ return `<div class="bm-wrap bm-page-pad bm-narrow"><div class="bm-empty" id="bm-not-found"><p class="bm-kicker">404</p><h1>Not here</h1><p>The page is not part of Bench's own origin. Boards are JSON under /api/v1, and the home explains the kinds.</p><a class="bm-btn" href="/">Back to Bench</a></div></div>`;
+}
+
+module.exports = { home, docs, notFound };
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+
+const assert = require('assert');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const { boot, check, done } = require('./helpers/boot');
+
+const SECRET = 'whsec_fixturefixturefixturefixturefixturefixture';
+const EXPORT_ID = 'exp_01M624SX2FFR7RMSXMFJ2GM16F';
+const DELETION_ID = 'del_01M624SX2FFR7RMSXMFJ2GM16F';
+
+(async () => {
+ const sent = [];
+ const accountSend = async (path, body) => {
+ sent.push({ path, body });
+ return { ok: true, status: 201 };
+ };
+ const t = await boot({ env: { BENCH_EVENTS_SECRET: SECRET, BENCH_ROUNDS_PER_DAY: '100', BENCH_ROUNDS_DAILY: '1000' }, accountSend });
+ const alice = t.network.addUser('alice');
+
+ let taskId;
+ let roundId;
+ let judgementId;
+
+ const signedEvent = (event) => {
+ const raw = JSON.stringify({ event });
+ return { raw, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(Buffer.from(raw), SECRET) } };
+ };
+
+ await check('a person leaves tasks, rounds and a judgement', async () => {
+ const carol = t.network.addUser('carol');
+ const other = await t.get('/api/v1/kinds/design.page/tasks', { as: carol, json: { prompt: 'A page for a human.', public: true } });
+ const otherRound = await t.get(`/api/v1/tasks/${other.json().task.id}/rounds`, { as: carol, json: {} });
+ await t.tick();
+
+ const task = await t.get('/api/v1/kinds/design.page/tasks', { as: alice, json: { prompt: 'A page for a robot.', public: true } });
+ assert.strictEqual(task.status, 201, task.text);
+ taskId = task.json().task.id;
+ const rr = await t.get(`/api/v1/tasks/${taskId}/rounds`, { as: alice, json: {} });
+ roundId = rr.json().round.id;
+
+ const pair = await t.get('/api/v1/judgements/next?kind=design.page', { as: alice });
+ assert.strictEqual(pair.status, 200, pair.text);
+ const vote = await t.get(`/api/v1/rounds/${otherRound.json().round.id}/judgements`, { as: alice, json: { judge: 'human_pairwise', pair: { left: pair.json().left.id, right: pair.json().right.id }, outcome: 'left' } });
+ assert.strictEqual(vote.status, 201, vote.text);
+ judgementId = vote.json().judgement.id;
+ });
+
+ await check('account export sends the person\'s rows to Network', async () => {
+ const ev = { event_id: 'evt_export_1', event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXPORT_ID, subject: alice.subject } };
+ const r = await t.get('/internal/events', signedEvent(ev));
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().outcome, 'exported');
+ const part = sent.find((c) => c.path === `/internal/account-exports/${EXPORT_ID}/parts`);
+ assert.ok(part, 'export part was not sent');
+ assert.strictEqual(part.body.subject, alice.subject);
+ assert.ok(part.body.files.some((f) => f.name === 'bench_tasks.json' && f.content.length === 1));
+ assert.ok(part.body.files.some((f) => f.name === 'bench_rounds.json' && f.content.length === 1));
+ assert.ok(part.body.files.some((f) => f.name === 'bench_judgements.json' && f.content.length === 1));
+ });
+
+ await check('account deletion removes tasks and rounds and anonymises votes', async () => {
+ const ev = { event_id: 'evt_delete_1', event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DELETION_ID, subject: alice.subject } };
+ const r = await t.get('/internal/events', signedEvent(ev));
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().outcome, 'erased');
+ const confirm = sent.find((c) => c.path === `/internal/account-deletions/${DELETION_ID}/confirmations`);
+ assert.ok(confirm, 'deletion confirmation was not sent');
+ assert.strictEqual(confirm.body.subject, alice.subject);
+ assert.strictEqual(confirm.body.erased.bench_tasks, 1);
+ assert.strictEqual(confirm.body.erased.bench_rounds, 1);
+ assert.strictEqual(confirm.body.retained.tombstones, 1);
+
+ const task = await t.get(`/api/v1/tasks/${taskId}`);
+ assert.strictEqual(task.status, 404, task.text);
+ const round = await t.get(`/api/v1/rounds/${roundId}`);
+ assert.strictEqual(round.status, 404, round.text);
+ const j = await t.ctx.s.db.maybe('SELECT subject FROM bench_judgements WHERE id = $1', [judgementId]);
+ assert.strictEqual(j.subject, null);
+ });
+
+ await check('stop', async () => { await t.close(); });
+
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
+'use strict';
+
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const svc = t.network.serviceToken({ sub: 'svc:games', audience: 'openvibe.bench', cap: ['bench.entry.create'] });
+
+ let taskId;
+ let roundId;
+ let leftId;
+ let rightId;
+
+ await check('kinds are seeded from code (the 15 in §4.12)', async () => {
+ const r = await t.get('/api/v1/kinds');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().kinds.length, 15);
+ const ids = r.json().kinds.map((k) => k.id);
+ for (const want of ['design.page', 'games.match', 'meme.caption', 'vision.answer', 'codes.patch', 'speech.tts', 'speech.asr', 'translate.ja', 'search.answer']) {
+ assert.ok(ids.includes(want), `missing kind ${want}`);
+ }
+ });
+
+ await check('a person creates a task and queues a round', async () => {
+ const r = await t.get('/api/v1/kinds/design.page/tasks', { as: alice, json: { prompt: 'A landing page for a tiny robot.', public: true } });
+ assert.strictEqual(r.status, 201, r.text);
+ taskId = r.json().task.id;
+ const rr = await t.get(`/api/v1/tasks/${taskId}/rounds`, { as: alice, json: {} });
+ assert.strictEqual(rr.status, 201, rr.text);
+ roundId = rr.json().round.id;
+ assert.strictEqual(rr.json().round.status, 'queued');
+ });
+
+ await check('the job generates entries and finishes the round with a receipt', async () => {
+ await t.tick();
+ const r = await t.get(`/api/v1/rounds/${roundId}`, { as: alice });
+ assert.strictEqual(r.status, 200, r.text);
+ const round = r.json().round;
+ assert.strictEqual(round.status, 'finished');
+ assert.strictEqual(round.entries.length, 3);
+ assert.ok(round.receipt && /^[0-9a-f]{64}$/.test(round.receipt), 'a round receipt is a hash');
+ });
+
+ await check('entries are blind to a stranger and revealed to the creator', async () => {
+ const anon = await t.get(`/api/v1/rounds/${roundId}`);
+ assert.strictEqual(anon.json().round.blind, true);
+ assert.strictEqual(anon.json().round.entries[0].model, undefined);
+ const mine = await t.get(`/api/v1/rounds/${roundId}`, { as: alice });
+ assert.ok(mine.json().round.entries[0].model, 'creator sees the model');
+ });
+
+ await check('next pair is blind and excludes the creator', async () => {
+ const none = await t.get('/api/v1/judgements/next?kind=design.page', { as: alice });
+ assert.strictEqual(none.json().pair, null);
+ const r = await t.get('/api/v1/judgements/next?kind=design.page', { as: bob });
+ assert.strictEqual(r.status, 200, r.text);
+ const pair = r.json();
+ assert.ok(pair.left && pair.right && pair.left.id !== pair.right.id);
+ assert.strictEqual(pair.left.model, undefined);
+ leftId = pair.left.id;
+ rightId = pair.right.id;
+ });
+
+ await check('a blind vote records, reveals, updates Elo and enforces one vote', async () => {
+ const r = await t.get(`/api/v1/rounds/${roundId}/judgements`, { as: bob, json: { pair: { left: leftId, right: rightId }, outcome: 'left' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.ok(r.json().revealed.left.model, 'revealed after the vote');
+ const again = await t.get(`/api/v1/rounds/${roundId}/judgements`, { as: bob, json: { pair: { left: leftId, right: rightId }, outcome: 'right' } });
+ assert.strictEqual(again.status, 409, again.text);
+ });
+
+ await check('a creator cannot vote on their own round', async () => {
+ const r = await t.get(`/api/v1/rounds/${roundId}/judgements`, { as: alice, json: { pair: { left: leftId, right: rightId }, outcome: 'left' } });
+ assert.strictEqual(r.status, 403, r.text);
+ });
+
+ await check('elo, pareto and cost boards render as JSON', async () => {
+ for (const type of ['elo', 'pareto', 'cost']) {
+ const r = await t.get(`/api/v1/boards/design.page?type=${type}`);
+ assert.strictEqual(r.status, 200, `${type}: ${r.text}`);
+ assert.strictEqual(r.json().board.type, type);
+ assert.ok(Array.isArray(r.json().board.rows));
+ }
+ });
+
+ await check('the open dataset has no voter ids', async () => {
+ const r = await t.get('/api/v1/datasets/design.page.jsonl');
+ assert.strictEqual(r.status, 200, r.text);
+ const lines = r.text.trim().split('\n').map((l) => JSON.parse(l));
+ const judgements = lines.filter((l) => l.type === 'judgement');
+ assert.ok(judgements.length >= 1);
+ for (const j of judgements) assert.strictEqual(j.subject, undefined);
+ });
+
+ await check('a model page aggregates ratings across kinds', async () => {
+ const reveal = await t.get(`/api/v1/rounds/${roundId}`, { as: alice });
+ const model = reveal.json().round.entries[0].model;
+ const r = await t.get(`/api/v1/models/${encodeURIComponent(model)}`);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().model, model);
+ assert.ok(Array.isArray(r.json().entries));
+ });
+
+ await check('a room service submits entries with a bench.entry.create token', async () => {
+ const rr = await t.get(`/api/v1/tasks/${taskId}/rounds`, { as: alice, json: {} });
+ const rid = rr.json().round.id;
+ const e1 = await t.get(`/api/v1/rounds/${rid}/entries`, { bearer: svc, json: { provider: 'acme', model: 'alpha-1', payload: { html: '<h1>One</h1>' } } });
+ assert.strictEqual(e1.status, 201, e1.text);
+ const e2 = await t.get(`/api/v1/rounds/${rid}/entries`, { bearer: svc, json: { provider: 'beta', model: 'beta-2', payload: { html: '<h1>Two</h1>' } } });
+ assert.strictEqual(e2.status, 201, e2.text);
+ const round = await t.get(`/api/v1/rounds/${rid}`, { as: alice });
+ assert.strictEqual(round.json().round.status, 'finished');
+ assert.strictEqual(round.json().round.entries.length, 2);
+ });
+
+ await check('a person\'s token is refused for outside entries', async () => {
+ const rr = await t.get(`/api/v1/tasks/${taskId}/rounds`, { as: alice, json: {} });
+ const rid = rr.json().round.id;
+ const person = await t.get(`/api/v1/rounds/${rid}/entries`, { as: alice, json: { provider: 'acme', model: 'alpha-1', payload: { html: '<h1>No</h1>' } } });
+ assert.strictEqual(person.status, 403, person.text);
+ assert.strictEqual(person.json().code, 'bench.capability_required');
+ const noCap = await t.get(`/api/v1/rounds/${rid}/entries`, { bearer: t.network.serviceToken({ sub: 'svc:nope', audience: 'openvibe.bench', cap: [] }), json: { provider: 'acme', model: 'alpha-1', payload: { html: '<h1>No</h1>' } } });
+ assert.strictEqual(noCap.status, 403, noCap.text);
+ });
+
+ await check('stop', async () => { await t.close(); });
+
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+'use strict';
+
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot({ env: { BENCH_ROUNDS_PER_DAY: '100', BENCH_ROUNDS_DAILY: '1000' } });
+ const alice = t.network.addUser('alice');
+ const entrySvc = t.network.serviceToken({ sub: 'svc:room', audience: 'openvibe.bench', cap: ['bench.entry.create'] });
+ const judgeSvc = t.network.serviceToken({ sub: 'svc:judge', audience: 'openvibe.bench', cap: ['bench.judgement.cast'] });
+
+ let roundId;
+ let leftId;
+ let rightId;
+
+ await check('elo, pareto and cost boards render for a seeded kind', async () => {
+ const task = await t.get('/api/v1/kinds/design.page/tasks', { as: alice, json: { prompt: 'A page for a robot.', public: true } });
+ const rr = await t.get(`/api/v1/tasks/${task.json().task.id}/rounds`, { as: alice, json: {} });
+ roundId = rr.json().round.id;
+ const e1 = await t.get(`/api/v1/rounds/${roundId}/entries`, { bearer: entrySvc, json: { provider: 'acme', model: 'alpha-1', payload: { html: '<h1>One</h1>' } } });
+ const e2 = await t.get(`/api/v1/rounds/${roundId}/entries`, { bearer: entrySvc, json: { provider: 'beta', model: 'beta-2', payload: { html: '<h1>Two</h1>' } } });
+ assert.strictEqual(e1.status, 201, e1.text);
+ assert.strictEqual(e2.status, 201, e2.text);
+ leftId = e1.json().entry.id;
+ rightId = e2.json().entry.id;
+ for (const type of ['elo', 'pareto', 'cost']) {
+ const r = await t.get(`/api/v1/boards/design.page?type=${type}`);
+ assert.strictEqual(r.status, 200, `${type}: ${r.text}`);
+ assert.strictEqual(r.json().board.type, type);
+ assert.ok(Array.isArray(r.json().board.rows));
+ }
+ });
+
+ await check('a board marks models with under 10 games as provisional', async () => {
+ let board = await t.get('/api/v1/boards/design.page?type=elo');
+ const subjects = board.json().board.rows;
+ assert.ok(subjects.length >= 2, 'expected at least two subjects');
+ for (const row of subjects) assert.strictEqual(row.provisional, true, `${row.subject} should be provisional with ${row.games} games`);
+ await t.get(`/api/v1/rounds/${roundId}/judgements`, { bearer: judgeSvc, json: { judge: 'game_outcome', pair: { left: leftId, right: rightId }, outcome: 'left' } });
+ board = await t.get('/api/v1/boards/design.page?type=elo');
+ const after = board.json().board.rows;
+ assert.ok(after.some((row) => row.games === 1 && row.provisional === true), 'one game is still provisional');
+ });
+
+ await check('a model page aggregates across kinds', async () => {
+ const task = await t.get('/api/v1/kinds/games.match/tasks', { as: alice, json: { prompt: 'A match.', public: true } });
+ const rr = await t.get(`/api/v1/tasks/${task.json().task.id}/rounds`, { as: alice, json: {} });
+ const rid = rr.json().round.id;
+ await t.get(`/api/v1/rounds/${rid}/entries`, { bearer: entrySvc, json: { provider: 'acme', model: 'alpha-1', payload: { result: { winner: 'left' } } } });
+ await t.get(`/api/v1/rounds/${rid}/entries`, { bearer: entrySvc, json: { provider: 'beta', model: 'beta-2', payload: { result: { winner: 'right' } } } });
+ const r = await t.get('/api/v1/models/alpha-1');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().model, 'alpha-1');
+ assert.ok(Array.isArray(r.json().entries));
+ assert.ok(r.json().entries.some((e) => e.kind_id === 'design.page'));
+ assert.ok(r.json().entries.some((e) => e.kind_id === 'games.match'));
+ assert.ok(Array.isArray(r.json().ratings));
+ });
+
+ await check('the open dataset has no voter id or person id', async () => {
+ const r = await t.get('/api/v1/datasets/design.page.jsonl');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.text.includes('usr_'), false, 'dataset contains a person id');
+ for (const line of r.text.trim().split('\n').map((l) => JSON.parse(l))) {
+ assert.strictEqual(line.created_by, undefined, 'task or round leaked a person id');
+ if (line.type === 'judgement') assert.strictEqual(line.subject, undefined, 'judgement leaked a voter id');
+ }
+ });
+
+ await check('stop', async () => { await t.close(); });
+
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'use strict';
+
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot({ env: { BENCH_EVALS_PER_DAY: '1' } });
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+
+ let evalId;
+ let runId;
+
+ await check('a private eval is created and run under the free daily cap', async () => {
+ const r = await t.get('/api/v1/evals', { as: alice, json: { kind_id: 'vision.answer', name: 'Held-out suite', test_suite: [{ prompt: 'What is 2 + 2?' }, { prompt: 'What is 3 + 3?' }] } });
+ assert.strictEqual(r.status, 201, r.text);
+ evalId = r.json().eval.id;
+ const run = await t.get(`/api/v1/evals/${evalId}/run`, { as: alice, json: { offers: [{ provider: 'acme', model: 'alpha-1' }] } });
+ assert.strictEqual(run.status, 202, run.text);
+ runId = run.json().run.id;
+ assert.strictEqual(run.json().run.status, 'queued');
+ });
+
+ await check('the daily cap refuses a second run the same day', async () => {
+ const run = await t.get(`/api/v1/evals/${evalId}/run`, { as: alice, json: { offers: [] } });
+ assert.strictEqual(run.status, 429, run.text);
+ });
+
+ await check('another owner cannot read the eval', async () => {
+ const r = await t.get(`/api/v1/evals/${evalId}`, { as: bob });
+ assert.strictEqual(r.status, 404, r.text);
+ });
+
+ await check('the job runs the suite and results stay private', async () => {
+ await t.tick();
+ const r = await t.get(`/api/v1/evals/${evalId}/results`, { as: alice });
+ assert.strictEqual(r.status, 200, r.text);
+ const run = r.json().runs.find((x) => x.id === runId);
+ assert.strictEqual(run.status, 'ready');
+ assert.ok(Array.isArray(run.results));
+ });
+
+ await check('stop', async () => { await t.close(); });
+
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot({ env: { BENCH_ROUNDS_PER_DAY: '100', BENCH_ROUNDS_DAILY: '1000' } });
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+
+ let roundId;
+
+ const topics = async () => (await t.ctx.s.db.many('SELECT topic, payload FROM bench_outbox ORDER BY created_at, event_id')).map((r) => r.topic);
+
+ await check('the outbox writes bench.round.finished when a round finishes', async () => {
+ const task = await t.get('/api/v1/kinds/design.page/tasks', { as: alice, json: { prompt: 'A page for a robot.', public: true } });
+ const rr = await t.get(`/api/v1/tasks/${task.json().task.id}/rounds`, { as: alice, json: {} });
+ roundId = rr.json().round.id;
+ await t.tick();
+ assert.ok((await topics()).includes('bench.round.finished'), 'expected bench.round.finished');
+ });
+
+ await check('the outbox writes bench.judgement.recorded and bench.rating.changed for a pairwise vote', async () => {
+ const pair = await t.get('/api/v1/judgements/next?kind=design.page', { as: bob });
+ assert.strictEqual(pair.status, 200, pair.text);
+ const vote = await t.get(`/api/v1/rounds/${roundId}/judgements`, { as: bob, json: { judge: 'human_pairwise', pair: { left: pair.json().left.id, right: pair.json().right.id }, outcome: 'left' } });
+ assert.strictEqual(vote.status, 201, vote.text);
+ const all = await topics();
+ assert.ok(all.includes('bench.judgement.recorded'), 'expected bench.judgement.recorded');
+ assert.ok(all.includes('bench.rating.changed'), 'expected bench.rating.changed');
+ });
+
+ await check('stop', async () => { await t.close(); });
+
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+'use strict';
+/**
+ * An in-process stand-in for OpenVibe.AI: GET /api/v1/routes?operation=generate lists offers, POST /api/v1/generate
+ * answers with a fake run. It verifies the client-credentials token Bench got from the Network stub (audience
+ * openvibe.ai, capability ai.run.create, namespace ai.generate) and records every request.
+ */
+const http = require('http');
+const crypto = require('crypto');
+
+const DEFAULT_MODELS = [
+ { provider: 'acme', model: 'alpha-1' },
+ { provider: 'beta', model: 'beta-2' },
+ { provider: 'gamma', model: 'gamma-3' },
+];
+
+function payload(token) {
+ const part = String(token || '').split('.')[1];
+ if (!part) return null;
+ try { return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')); } catch { return null; }
+}
+
+function listen(handler) {
+ return new Promise((resolve) => {
+ const sockets = new Set();
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks);
+ const json = (status, obj, headers = {}) => { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); };
+ Promise.resolve(handler(req, raw, json, res)).catch((err) => json(500, { code: 'stub.error', detail: String(err && err.message) }));
+ });
+ });
+ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
+ server.listen(0, '127.0.0.1', () => resolve({
+ server, url: `http://127.0.0.1:${server.address().port}`,
+ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),
+ }));
+ });
+}
+
+async function startAi({ models = DEFAULT_MODELS, costUsd = 0.0012, ms = 321, tokensIn = 120, tokensOut = 480, textFor = null } = {}) {
+ const st = { models: models.map((m) => ({ ...m })), requests: [], runCount: 0, runs: new Map(), costUsd, ms, tokensIn, tokensOut, textFor };
+
+ function modelFor(body) {
+ const asked = body && body.route;
+ if (asked && typeof asked === 'object') {
+ const hit = st.models.find((x) => x.provider === asked.provider && (asked.model == null || x.model === asked.model));
+ if (hit) return hit;
+ return false;
+ }
+ return st.models[st.runCount % st.models.length] || { provider: 'stub', model: 'stub-1' };
+ }
+
+ const srv = await listen(async (req, raw, json) => {
+ const url = new URL(req.url, 'http://x');
+ const text = raw.toString('utf8');
+ let body = {};
+ try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }
+ const auth = String(req.headers.authorization || '');
+ const claims = auth.startsWith('Bearer ') ? payload(auth.slice(7)) : null;
+ if (!claims || !(claims.aud || []).includes('openvibe.ai') || !(claims.cap || []).includes('ai.run.create')) {
+ return json(401, { code: 'token.invalid', detail: 'a Bench client-credentials token with ai.run.create is required' });
+ }
+ if (!(claims.ns || []).some((n) => n === 'ai.generate' || n === 'ai.*')) {
+ return json(403, { code: 'namespace.denied', detail: 'the token needs the ai.generate namespace' });
+ }
+
+ if (url.pathname === '/api/v1/routes' && req.method === 'GET') {
+ st.requests.push({ method: 'GET', path: url.pathname, body });
+ const [first, ...rest] = st.models;
+ return json(200, { routes: [{ key: 'default.chat', version: 3, status: 'active', capability: 'chat', primary: { provider: first.provider, model: first.model }, fallbacks: rest.map((m) => ({ provider: m.provider, model: m.model })), pinned: [], constraints: {}, offers: st.models.map((m) => ({ provider: m.provider, model: m.model })) }] });
+ }
+
+ if (url.pathname === '/api/v1/generate' && req.method === 'POST') {
+ st.requests.push({ method: 'POST', path: url.pathname, body, claims });
+ const pick = modelFor(body);
+ if (pick === false) return json(422, { code: 'input.invalid', detail: `route ${body.route.provider}/${body.route.model} is not offered` });
+ const id = `run_${crypto.randomBytes(8).toString('hex')}`;
+ const make = st.textFor || textFor;
+ const text = make ? make(body, pick, st) : `<!doctype html><html><body><h1>Generated output</h1><p>${String(body.prompt || '').split('\n')[0].slice(0, 200)}</p></body></html>`;
+ const run = {
+ id, status: 'succeeded', workflow: { key: 'ai.generate', version: 1 },
+ provenance: { provider: pick.provider, model: pick.model, run_id: id },
+ output: { text },
+ usage: { tokens_in: st.tokensIn, tokens_out: st.tokensOut, cost_usd: st.costUsd, attempts: 1 },
+ started_at: new Date(Date.now() - st.ms).toISOString(), finished_at: new Date().toISOString(), created_at: new Date().toISOString(),
+ };
+ st.runs.set(id, run);
+ st.runCount++;
+ return json(201, { run });
+ }
+
+ const one = /^\/api\/v1\/runs\/([^/]+)$/.exec(url.pathname);
+ if (one && req.method === 'GET') {
+ const run = st.runs.get(decodeURIComponent(one[1]));
+ if (!run) return json(404, { code: 'run.not_found' });
+ return json(200, { run });
+ }
+
+ return json(404, { code: 'not_found', detail: 'not found' });
+ });
+
+ return { url: srv.url, state: st, models: st.models, requests: st.requests, close: srv.close };
+}
+
+module.exports = { startAi, DEFAULT_MODELS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+'use strict';
+// Many requests per test file: the per-address limits would answer 429 part-way.
+process.env.BENCH_RATE_LIMIT_PER_MIN = process.env.BENCH_RATE_LIMIT_PER_MIN || '100000';
+process.env.BENCH_API_RATE_LIMIT_PER_MIN = process.env.BENCH_API_RATE_LIMIT_PER_MIN || '100000';
+process.env.BENCH_LIMITS_MINUTE = process.env.BENCH_LIMITS_MINUTE || '100000';
+process.env.BENCH_LIMITS_HOUR = process.env.BENCH_LIMITS_HOUR || '100000';
+/**
+ * Boots OpenVibe.Bench on a temp database with a mock Network and a fake AI, captures log lines, and returns a
+ * small HTTP client. Every test file gets its own process, database, Network, AI and app.
+ */
+const http = require('http');
+const { startNetwork } = require('./network');
+const { startAi } = require('./ai');
+
+async function boot(opts = {}) {
+ const network = await startNetwork(opts.network || {});
+ const ai = opts.ai || await startAi(opts.aiOptions || {});
+ const env = {
+ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://bench.openvibe.services', TRUST_PROXY: '1',
+ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url, OV_NETWORK_ISSUER: network.url,
+ OV_OAUTH_CLIENT_ID: 'bench', OV_OAUTH_CLIENT_SECRET: 'bench-secret', COOKIE_SECURE: 'false',
+ OV_AI_INTERNAL_URL: ai.url,
+ BENCH_DRILL: '1',
+ BENCH_AI_POLL_MS: '10', BENCH_AI_WAIT_MS: '5000', BENCH_JOB_INTERVAL_MS: '100000',
+ ...(opts.env || {}),
+ };
+ for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];
+
+ const logLines = [];
+ const log = {
+ log: (...a) => logLines.push(a.map(String).join(' ')),
+ info: (...a) => logLines.push(a.map(String).join(' ')),
+ warn: (...a) => logLines.push(a.map(String).join(' ')),
+ error: (...a) => logLines.push(a.map(String).join(' ')),
+ };
+
+ const configLib = require('../../server/config');
+ const { createApp } = require('../../server/app');
+ const { createStore } = require('../../server/db');
+ const config = configLib.load(env);
+ const testdb = opts.store ? null : await require('./db').testDb({ store: opts.testStore });
+ const store = opts.store || createStore(testdb.db, { now: opts.now });
+ const built = await createApp({ config, store, benchAi: opts.benchAi, log, now: opts.now, accountSend: opts.accountSend, random: opts.random, telemetrySink: opts.telemetrySink });
+ await built.ctx.keys.ensure();
+ const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+
+ const signIn = (user) => `bench_at=${network.userToken(user)}`;
+
+ async function get(p, o = {}) {
+ const headers = { ...(o.headers || {}) };
+ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');
+ if (o.cookie) headers.cookie = o.cookie;
+ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;
+ let body = o.body;
+ if (o.raw !== undefined) { body = o.raw; }
+ else if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
+ const method = o.method || (body ? 'POST' : 'GET');
+ if (o.as && method !== 'GET' && method !== 'HEAD' && !headers.origin && !headers['sec-fetch-site']) headers.origin = config.baseUrl;
+ if (o.origin) headers.origin = o.origin;
+ if (o.secFetchSite) headers['sec-fetch-site'] = o.secFetchSite;
+ const res = await fetch(base + p, { method, headers, body, redirect: 'manual' });
+ const buf = Buffer.from(await res.arrayBuffer());
+ const text = buf.toString('utf8');
+ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };
+ }
+
+ async function dbDump() {
+ const db = built.ctx.s.db;
+ const tables = (await db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()")).map((r) => r.name);
+ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.many(`SELECT * FROM "${tname}"`))))).join('\n');
+ }
+
+ return {
+ base, network, ai, config, ctx: built.ctx, store: built.ctx.s, get, signIn, dbDump,
+ tick: () => built.ctx.jobs.tick(),
+ logs: () => logLines.join('\n'),
+ async close() {
+ built.ctx.jobs.stop();
+ built.ctx.publisher.stop();
+ await new Promise((r) => server.close(r));
+ built.ctx.keys.client.stop();
+ await built.ctx.telemetry.stop().catch(() => {});
+ if (testdb) await testdb.close();
+ await network.close();
+ if (!opts.ai) await ai.close();
+ },
+ };
+}
+
+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, 10).join('\n ')}\n`);
+ }
+}
+function done() {
+ process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n');
+ process.exit(failures ? 1 : 0);
+}
+
+module.exports = { boot, check, done };
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+'use strict';
+/**
+ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
+ * BENCH_TEST_STORE=pg the PostgreSQL + PgBouncer containers.
+ */
+const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');
+const { MIGRATIONS } = require('../../server/db');
+
+const testDb = ({ store = process.env.BENCH_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'bench', max });
+
+module.exports = { testDb, pgAvailable };
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
+'use strict';
+/**
+ * An in-process stand-in for OpenVibe.Network with a real RS256 key pair: JWKS, /oauth/token (authorization_code +
+ * PKCE S256 for client `bench`, refresh_token, client_credentials), /oauth/revoke. Every request is recorded.
+ */
+const http = require('http');
+const crypto = require('crypto');
+const { serviceAuth, ids } = require('openvibe-contracts');
+
+function listen(handler) {
+ return new Promise((resolve) => {
+ const sockets = new Set();
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks);
+ const json = (status, obj, headers = {}) => { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); };
+ Promise.resolve(handler(req, raw, json, res)).catch((err) => { json(500, { error: String(err && err.message) }); });
+ });
+ });
+ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
+ server.listen(0, '127.0.0.1', () => resolve({
+ server, url: `http://127.0.0.1:${server.address().port}`,
+ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),
+ }));
+ });
+}
+
+const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
+function signJwt(claims, privateKey) {
+ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}`;
+ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
+}
+
+async function startNetwork() {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
+ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
+ const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], down: false };
+ let issuer = null;
+ let nextUserId = 1;
+
+ const problem = (json, status, code, detail) => json(status, { type: `https://openvibe.network/problems/${code}`, title: 'Error', status, code, detail, error: detail, request_id: `req_${crypto.randomBytes(6).toString('hex')}` }, { 'Content-Type': 'application/problem+json' });
+
+ function addUser(username, { role = 'user' } = {}) {
+ const u = { id: nextUserId++, subject: ids.newId('user'), username, display_name: username, role };
+ st.users.set(u.id, u); st.byUsername.set(username, u);
+ return u;
+ }
+ function userToken(u, { ttl = 3600, iat = null } = {}) {
+ const now = iat != null ? iat : Math.floor(Date.now() / 1000);
+ return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);
+ }
+ function serviceToken({ sub = 'svc:test', audience = 'openvibe.bench', cap = [], ns = [], ttl = 300 } = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ return signJwt({ iss: issuer, sub, actor_type: 'service', aud: [audience], cap, ns, iat: now, exp: now + ttl, jti: crypto.randomUUID() }, privatePem);
+ }
+ function userFrom(req) {
+ const h = String(req.headers.authorization || '');
+ if (!h.startsWith('Bearer ')) return null;
+ const parts = h.slice(7).split('.');
+ if (parts.length !== 3) return null;
+ const ok = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicPem, Buffer.from(parts[2], 'base64url'));
+ if (!ok) return null;
+ const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
+ if (c.exp * 1000 < Date.now()) return 'expired';
+ return st.users.get(c.sub) || null;
+ }
+
+ const srv = await listen(async (req, raw, json) => {
+ const url = new URL(req.url, 'http://x');
+ st.requests.push({ method: req.method, path: url.pathname, query: url.search, headers: req.headers, body: raw.toString('utf8') });
+ if (st.down) return problem(json, 503, 'network.down', 'Network is restarting');
+
+ if (url.pathname === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
+ if (url.pathname === '/oauth/revoke') return json(200, {});
+ if (url.pathname === '/oauth/token' && req.method === 'POST') {
+ const ct = String(req.headers['content-type'] || '');
+ const body = ct.includes('json') ? JSON.parse(raw.toString('utf8') || '{}') : Object.fromEntries(new URLSearchParams(raw.toString('utf8')));
+ st.tokenRequests.push({ grant_type: body.grant_type, client_id: body.client_id, audience: body.audience, scope: body.scope });
+ if (body.client_id !== 'bench' || body.client_secret !== 'bench-secret') return json(401, { error: 'invalid_client', error_description: 'bad client secret' });
+ if (body.grant_type === 'authorization_code') {
+ const c = st.codes.get(body.code);
+ if (!c) return json(400, { error: 'invalid_grant', error_description: 'unknown code' });
+ st.codes.delete(body.code);
+ const challenge = crypto.createHash('sha256').update(String(body.code_verifier || '')).digest('base64url');
+ if (challenge !== c.challenge) return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
+ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;
+ st.refresh.set(rt, c.user);
+ return json(200, { access_token: userToken(c.user), refresh_token: rt, token_type: 'Bearer' });
+ }
+ if (body.grant_type === 'refresh_token') {
+ const user = st.refresh.get(body.refresh_token);
+ if (!user) return json(400, { error: 'invalid_grant', error_description: 'refresh token rejected' });
+ st.refresh.delete(body.refresh_token);
+ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;
+ st.refresh.set(rt, user);
+ return json(200, { access_token: userToken(user), refresh_token: rt, token_type: 'Bearer' });
+ }
+ if (body.grant_type === 'client_credentials') {
+ const now = Math.floor(Date.now() / 1000);
+ const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
+ const audience = body.audience || 'openvibe.bench';
+ const ns = audience === 'openvibe.ai' ? ['ai.generate'] : [];
+ return json(200, {
+ access_token: serviceAuth.signServiceToken({ iss: issuer, sub: 'svc:bench', actor_type: 'service', aud: [audience], cap, ns, iat: now, exp: now + 300, jti: crypto.randomUUID() }, privatePem),
+ token_type: 'Bearer', expires_in: 300, scope: cap.join(' '),
+ });
+ }
+ return json(400, { error: 'unsupported_grant_type' });
+ }
+
+ return json(404, { error: 'not found' });
+ });
+ issuer = srv.url;
+
+ return {
+ url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,
+ addUser, userToken, serviceToken, signJwt: (claims) => signJwt(claims, privatePem), issuer: () => issuer,
+ issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
+ setDown(v) { st.down = v; },
+ close: srv.close,
+ };
+}
+
+module.exports = { startNetwork, signJwt };
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+'use strict';
+
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot({ env: { BENCH_ROUNDS_PER_DAY: '100', BENCH_ROUNDS_DAILY: '1000' } });
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const entrySvc = t.network.serviceToken({ sub: 'svc:room', audience: 'openvibe.bench', cap: ['bench.entry.create'] });
+ const judgeSvc = t.network.serviceToken({ sub: 'svc:judge', audience: 'openvibe.bench', cap: ['bench.judgement.cast'] });
+
+ let winRound;
+ let winPair;
+
+ async function makeRound(kindId = 'design.page', models = ['acme/alpha-1', 'beta/beta-2']) {
+ const task = await t.get(`/api/v1/kinds/${kindId}/tasks`, { as: alice, json: { prompt: 'A page for a robot.', public: true } });
+ assert.strictEqual(task.status, 201, task.text);
+ const rr = await t.get(`/api/v1/tasks/${task.json().task.id}/rounds`, { as: alice, json: {} });
+ assert.strictEqual(rr.status, 201, rr.text);
+ const roundId = rr.json().round.id;
+ const ids = [];
+ for (const name of models) {
+ const [provider, model] = name.split('/');
+ const e = await t.get(`/api/v1/rounds/${roundId}/entries`, {
+ bearer: entrySvc,
+ json: { provider, model, payload: { html: `<h1>${model}</h1>` } },
+ });
+ assert.strictEqual(e.status, 201, e.text);
+ ids.push(e.json().entry.id);
+ }
+ return { roundId, entryIds: ids };
+ }
+
+ await check('human pairwise: a win is blind before the vote and revealed after it', async () => {
+ const { roundId } = await makeRound();
+ const pair = await t.get('/api/v1/judgements/next?kind=design.page', { as: bob });
+ assert.strictEqual(pair.status, 200, pair.text);
+ assert.strictEqual(pair.json().left.model, undefined, 'blind before the vote');
+ assert.strictEqual(pair.json().right.model, undefined, 'blind before the vote');
+ const vote = await t.get(`/api/v1/rounds/${roundId}/judgements`, { as: bob, json: { judge: 'human_pairwise', pair: { left: pair.json().left.id, right: pair.json().right.id }, outcome: 'left' } });
+ assert.strictEqual(vote.status, 201, vote.text);
+ assert.ok(vote.json().revealed.left.model, 'revealed after the vote');
+ assert.ok(vote.json().revealed.right.model, 'revealed after the vote');
+ winRound = roundId;
+ winPair = { left: pair.json().left.id, right: pair.json().right.id };
+ });
+
+ await check('human pairwise: one vote per person per round', async () => {
+ const again = await t.get(`/api/v1/rounds/${winRound}/judgements`, { as: bob, json: { judge: 'human_pairwise', pair: winPair, outcome: 'right' } });
+ assert.strictEqual(again.status, 409, again.text);
+ });
+
+ await check('human pairwise: the creator cannot vote on their own round', async () => {
+ const vote = await t.get(`/api/v1/rounds/${winRound}/judgements`, { as: alice, json: { judge: 'human_pairwise', pair: winPair, outcome: 'left' } });
+ assert.strictEqual(vote.status, 403, vote.text);
+ });
+
+ await check('human pairwise: a tie is recorded', async () => {
+ const { roundId } = await makeRound();
+ const pair = await t.get('/api/v1/judgements/next?kind=design.page', { as: bob });
+ const vote = await t.get(`/api/v1/rounds/${roundId}/judgements`, { as: bob, json: { judge: 'human_pairwise', pair: { left: pair.json().left.id, right: pair.json().right.id }, outcome: 'tie' } });
+ assert.strictEqual(vote.status, 201, vote.text);
+ assert.strictEqual(vote.json().judgement.outcome, 'tie');
+ });
+
+ await check('human pairwise: both_bad is recorded and does not change ratings', async () => {
+ const { roundId } = await makeRound();
+ const pair = await t.get('/api/v1/judgements/next?kind=design.page', { as: bob });
+ const vote = await t.get(`/api/v1/rounds/${roundId}/judgements`, { as: bob, json: { judge: 'human_pairwise', pair: { left: pair.json().left.id, right: pair.json().right.id }, outcome: 'both_bad' } });
+ assert.strictEqual(vote.status, 201, vote.text);
+ assert.strictEqual(vote.json().judgement.outcome, 'both_bad');
+ });
+
+ await check('rubric: a person casts a score and outcome', async () => {
+ const { roundId } = await makeRound();
+ const vote = await t.get(`/api/v1/rounds/${roundId}/judgements`, { as: alice, json: { judge: 'rubric', judge_name: 'rubric-v1', judge_version: '1.0.0', score: 0.75, outcome: 'pass' } });
+ assert.strictEqual(vote.status, 201, vote.text);
+ assert.strictEqual(vote.json().judgement.judge, 'rubric');
+ assert.strictEqual(vote.json().judgement.score, 0.75);
+ assert.strictEqual(vote.json().judgement.outcome, 'pass');
+ });
+
+ await check('automatic grader: name and version are recorded', async () => {
+ const { roundId } = await makeRound('vision.answer');
+ const vote = await t.get(`/api/v1/rounds/${roundId}/judgements`, { bearer: judgeSvc, json: { judge: 'automatic_grader', judge_name: 'vision-grader', judge_version: 'v3.2.1', score: 1, outcome: 'pass' } });
+ assert.strictEqual(vote.status, 201, vote.text);
+ assert.strictEqual(vote.json().judgement.judge, 'automatic_grader');
+ assert.strictEqual(vote.json().judgement.judge_name, 'vision-grader');
+ assert.strictEqual(vote.json().judgement.judge_version, 'v3.2.1');
+ });
+
+ await check('test suite: name and version are recorded', async () => {
+ const { roundId } = await makeRound('codes.patch');
+ const vote = await t.get(`/api/v1/rounds/${roundId}/judgements`, { bearer: judgeSvc, json: { judge: 'test_suite', judge_name: 'patch-tests', judge_version: 'v2.0.0', score: 3, outcome: 'pass' } });
+ assert.strictEqual(vote.status, 201, vote.text);
+ assert.strictEqual(vote.json().judgement.judge, 'test_suite');
+ assert.strictEqual(vote.json().judgement.judge_name, 'patch-tests');
+ assert.strictEqual(vote.json().judgement.judge_version, 'v2.0.0');
+ });
+
+ await check('game outcome: a service casts a draw from a pair', async () => {
+ const { roundId, entryIds } = await makeRound('games.match', ['acme/alpha-1', 'beta/beta-2']);
+ const vote = await t.get(`/api/v1/rounds/${roundId}/judgements`, { bearer: judgeSvc, json: { judge: 'game_outcome', pair: { left: entryIds[0], right: entryIds[1] }, outcome: 'draw' } });
+ assert.strictEqual(vote.status, 201, vote.text);
+ assert.strictEqual(vote.json().judgement.judge, 'game_outcome');
+ assert.strictEqual(vote.json().judgement.outcome, 'draw');
+ });
+
+ await check('stop', async () => { await t.close(); });
+
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
+'use strict';
+
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const rpc = (method, params, id = 1) => ({ jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) });
+
+(async () => {
+ const t = await boot({ env: { BENCH_ROUNDS_PER_DAY: '100', BENCH_ROUNDS_DAILY: '1000' } });
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+
+ let roundId;
+ let leftId;
+ let rightId;
+
+ await check('initialize negotiates and names the server', async () => {
+ const r = await t.get('/mcp', { json: rpc('initialize', { protocolVersion: '2025-06-18' }, 1) });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().result.serverInfo.name, 'openvibe-bench');
+ assert.strictEqual(r.json().result.protocolVersion, '2025-06-18');
+ });
+
+ await check('initialize falls back to the current protocol for an unknown version', async () => {
+ const r = await t.get('/mcp', { json: rpc('initialize', { protocolVersion: '2099-01-01' }, 2) });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().result.protocolVersion, '2025-06-18');
+ });
+
+ await check('ping answers an empty result', async () => {
+ const r = await t.get('/mcp', { json: rpc('ping', {}, 3) });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.json().result, {});
+ });
+
+ await check('tools/list has the six §4.12 tools', async () => {
+ const r = await t.get('/mcp', { json: rpc('tools/list', {}, 4) });
+ const names = r.json().result.tools.map((x) => x.name);
+ assert.deepStrictEqual(names, ['bench.list_kinds', 'bench.start_round', 'bench.next_pair', 'bench.cast_judgement', 'bench.get_board', 'bench.run_eval']);
+ });
+
+ await check('an unknown method and a malformed message are JSON-RPC errors', async () => {
+ const unknown = await t.get('/mcp', { json: rpc('does.not_exist', {}, 5) });
+ assert.strictEqual(unknown.status, 200, unknown.text);
+ assert.strictEqual(unknown.json().error.code, -32601);
+ const malformed = await t.get('/mcp', { json: { jsonrpc: '2.0', id: 6, method: 3 } });
+ assert.strictEqual(malformed.status, 200, malformed.text);
+ assert.strictEqual(malformed.json().error.code, -32600);
+ });
+
+ await check('an unknown tool is a JSON-RPC error', async () => {
+ const r = await t.get('/mcp', { json: rpc('tools/call', { name: 'bench.nope', arguments: {} }, 7) });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().error.code, -32602);
+ assert.strictEqual(r.json().error.message, 'Unknown tool');
+ });
+
+ await check('bench.list_kinds lists the fifteen kinds', async () => {
+ const r = await t.get('/mcp', { json: rpc('tools/call', { name: 'bench.list_kinds', arguments: {} }, 8) });
+ assert.strictEqual(r.json().result.isError, false);
+ assert.strictEqual(r.json().result.structuredContent.kinds.length, 15);
+ });
+
+ await check('bench.get_board renders a board without a person', async () => {
+ const r = await t.get('/mcp', { json: rpc('tools/call', { name: 'bench.get_board', arguments: { kind: 'design.page', type: 'elo' } }, 9) });
+ assert.strictEqual(r.json().result.isError, false);
+ assert.strictEqual(r.json().result.structuredContent.board.type, 'elo');
+ });
+
+ await check('bench.get_board reports a missing kind as a tool error', async () => {
+ const r = await t.get('/mcp', { json: rpc('tools/call', { name: 'bench.get_board', arguments: { kind: 'no.such', type: 'elo' } }, 10) });
+ assert.strictEqual(r.json().result.isError, true);
+ assert.match(r.json().result.content[0].text, /No such kind/);
+ });
+
+ await check('a person-only tool is a tool error when anonymous', async () => {
+ for (const call of [
+ { name: 'bench.next_pair', arguments: { kind: 'design.page' } },
+ { name: 'bench.cast_judgement', arguments: { round: 'brd_nope', left: 'bte_1', right: 'bte_2', outcome: 'left' } },
+ { name: 'bench.start_round', arguments: { kind: 'design.page', prompt: 'x' } },
+ { name: 'bench.run_eval', arguments: { kind: 'vision.answer', test_suite: [{ prompt: 'x' }] } },
+ ]) {
+ const r = await t.get('/mcp', { json: rpc('tools/call', call, 11) });
+ assert.strictEqual(r.json().result.isError, true, `${call.name} should be a person error`);
+ assert.match(r.json().result.content[0].text, /person/);
+ }
+ });
+
+ await check('bench.start_round creates a task and round for a person', async () => {
+ const r = await t.get('/mcp', { as: alice, json: rpc('tools/call', { name: 'bench.start_round', arguments: { kind: 'design.page', prompt: 'A page for a robot.' } }, 12) });
+ assert.strictEqual(r.json().result.isError, false, JSON.stringify(r.json().result));
+ assert.match(r.json().result.structuredContent.id, /^brd_/);
+ roundId = r.json().result.structuredContent.id;
+ await t.tick();
+ const round = await t.get(`/api/v1/rounds/${roundId}`, { as: alice });
+ assert.strictEqual(round.json().round.status, 'finished');
+ });
+
+ await check('bench.start_round with no prompt is a tool error', async () => {
+ const r = await t.get('/mcp', { as: alice, json: rpc('tools/call', { name: 'bench.start_round', arguments: { kind: 'design.page' } }, 13) });
+ assert.strictEqual(r.json().result.isError, true);
+ assert.match(r.json().result.content[0].text, /prompt/);
+ });
+
+ await check('bench.run_eval with no suite is a tool error', async () => {
+ const r = await t.get('/mcp', { as: alice, json: rpc('tools/call', { name: 'bench.run_eval', arguments: { kind: 'vision.answer' } }, 14) });
+ assert.strictEqual(r.json().result.isError, true);
+ assert.match(r.json().result.content[0].text, /test_suite/);
+ });
+
+ await check('bench.cast_judgement records a blind vote and reveals the entries', async () => {
+ const pair = await t.get('/api/v1/judgements/next?kind=design.page', { as: bob });
+ assert.strictEqual(pair.status, 200, pair.text);
+ assert.ok(pair.json().left && pair.json().right);
+ leftId = pair.json().left.id;
+ rightId = pair.json().right.id;
+ const r = await t.get('/mcp', { as: bob, json: rpc('tools/call', { name: 'bench.cast_judgement', arguments: { round: roundId, left: leftId, right: rightId, outcome: 'left' } }, 15) });
+ assert.strictEqual(r.json().result.isError, false, JSON.stringify(r.json().result));
+ assert.ok(r.json().result.structuredContent.revealed.left.model, 'revealed after the vote');
+ });
+
+ await check('stop', async () => { await t.close(); });
+
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot({ env: { BENCH_EVALS_PER_DAY: '1', BENCH_ROUNDS_PER_DAY: '100', BENCH_ROUNDS_DAILY: '1000' } });
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const entrySvc = t.network.serviceToken({ sub: 'svc:room', audience: 'openvibe.bench', cap: ['bench.entry.create'] });
+
+ let evalId;
+ let runId;
+
+ await check('a private eval is created and run under the free daily cap', async () => {
+ const created = await t.get('/api/v1/evals', { as: alice, json: { kind_id: 'vision.answer', name: 'Held-out suite', test_suite: [{ prompt: 'What is 2 + 2?' }, { prompt: 'What is 3 + 3?' }] } });
+ assert.strictEqual(created.status, 201, created.text);
+ evalId = created.json().eval.id;
+ const run = await t.get(`/api/v1/evals/${evalId}/run`, { as: alice, json: { offers: [{ provider: 'acme', model: 'alpha-1' }] } });
+ assert.strictEqual(run.status, 202, run.text);
+ runId = run.json().run.id;
+ assert.strictEqual(run.json().run.status, 'queued');
+ });
+
+ await check('the daily cap refuses a second run the same day', async () => {
+ const run = await t.get(`/api/v1/evals/${evalId}/run`, { as: alice, json: { offers: [] } });
+ assert.strictEqual(run.status, 429, run.text);
+ });
+
+ await check('another owner cannot read the eval or its results', async () => {
+ const r = await t.get(`/api/v1/evals/${evalId}`, { as: bob });
+ assert.strictEqual(r.status, 404, r.text);
+ const results = await t.get(`/api/v1/evals/${evalId}/results`, { as: bob });
+ assert.strictEqual(results.status, 404, results.text);
+ });
+
+ await check('the job runs the suite and results stay private', async () => {
+ await t.tick();
+ const r = await t.get(`/api/v1/evals/${evalId}/results`, { as: alice });
+ assert.strictEqual(r.status, 200, r.text);
+ const run = r.json().runs.find((x) => x.id === runId);
+ assert.strictEqual(run.status, 'ready');
+ assert.ok(Array.isArray(run.results));
+ assert.strictEqual(r.json().eval.owner, alice.subject);
+ });
+
+ await check('nothing private ever appears in the open dataset', async () => {
+ const r = await t.get('/api/v1/datasets/vision.answer.jsonl');
+ assert.strictEqual(r.status, 200, r.text);
+ const lines = r.text.trim() ? r.text.trim().split('\n').map((l) => JSON.parse(l)) : [];
+ assert.ok(lines.every((line) => !line.eval_id && !line.owner && !line.subject), 'dataset line leaked private fields');
+ });
+
+ await check('a private eval never appears on a public board', async () => {
+ const task = await t.get('/api/v1/kinds/vision.answer/tasks', { as: alice, json: { prompt: 'A public task.', public: true } });
+ const rr = await t.get(`/api/v1/tasks/${task.json().task.id}/rounds`, { as: alice, json: {} });
+ const roundId = rr.json().round.id;
+ await t.get(`/api/v1/rounds/${roundId}/entries`, { bearer: entrySvc, json: { provider: 'acme', model: 'alpha-1', payload: { answer: 'public' } } });
+ await t.get(`/api/v1/rounds/${roundId}/entries`, { bearer: entrySvc, json: { provider: 'beta', model: 'beta-2', payload: { answer: 'public' } } });
+ for (const type of ['elo', 'pareto', 'cost']) {
+ const board = await t.get(`/api/v1/boards/vision.answer?type=${type}`);
+ assert.strictEqual(board.status, 200, `${type}: ${board.text}`);
+ assert.ok(board.json().board.rows.every((row) => !String(row.subject).startsWith('bev_') && row.subject !== runId), 'private eval leaked into board');
+ }
+ });
+
+ await check('the open dataset has no voter id or person id anywhere', async () => {
+ const r = await t.get('/api/v1/datasets/vision.answer.jsonl');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.text.includes('usr_'), false, 'dataset contains a person id');
+ for (const line of r.text.trim().split('\n').map((l) => JSON.parse(l))) {
+ assert.strictEqual(line.subject, undefined, 'judgement leaked a voter id');
+ assert.strictEqual(line.created_by, undefined, 'task or round leaked a person id');
+ assert.strictEqual(line.owner, undefined, 'eval leaked an owner id');
+ }
+ });
+
+ await check('stop', async () => { await t.close(); });
+
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { eloUpdate, bradleyTerry, voterWeights, wilson, pareto, costPerSolved, percentile } = require('../server/rating/lib');
+
+const game = (a, b, outcome, voter) => ({ a, b, outcome, voter });
+const bySubject = fit => new Map(fit.map(row => [row.subject, row]));
+
+test('Elo produces known equal-rating win, tie, and both_bad values', () => {
+ assert.deepEqual(eloUpdate(1000, 1000, 'a'), [1016, 984]);
+ assert.deepEqual(eloUpdate(1000, 1000, 'tie'), [1000, 1000]);
+ assert.deepEqual(eloUpdate(1000, 1000, 'both_bad'), [1000, 1000]);
+ assert.deepEqual(eloUpdate(1000, 1000, 'b', 16), [992, 1008]);
+ assert.ok(Math.abs(eloUpdate(1200, 1000, 'a')[0] - 1207.6880983472654) < 1e-9);
+});
+
+test('Bradley-Terry recovers a planted three-subject order', () => {
+ const games = [
+ ...Array.from({ length: 18 }, () => game('A', 'B', 'a')),
+ ...Array.from({ length: 2 }, () => game('A', 'B', 'b')),
+ ...Array.from({ length: 15 }, () => game('B', 'C', 'a')),
+ ...Array.from({ length: 5 }, () => game('B', 'C', 'b')),
+ ...Array.from({ length: 19 }, () => game('A', 'C', 'a')),
+ game('A', 'C', 'b'),
+ ];
+ const fit = bradleyTerry(games, { bootstrap: 0 });
+ assert.deepEqual(fit.map(row => row.subject), ['A', 'B', 'C']);
+ assert.ok(Math.abs(fit.reduce((sum, row) => sum + row.rating, 0) / 3 - 1000) < 1e-9);
+ assert.deepEqual(fit.map(row => row.games), [40, 40, 40]);
+ assert.ok(fit.every(row => !row.provisional));
+});
+
+test('ties count as half a win for each side and both_bad is skipped', () => {
+ const fit = bradleyTerry([game('A', 'B', 'tie'), game('A', 'B', 'both_bad')], { bootstrap: 0 });
+ assert.equal(fit[0].rating, 1000);
+ assert.equal(fit[1].rating, 1000);
+ assert.deepEqual(fit.map(row => [row.games, row.wins, row.losses, row.ties]), [[1, 0, 0, 1], [1, 0, 0, 1]]);
+ assert.ok(fit.every(row => row.provisional));
+ assert.deepEqual(bradleyTerry([game('A', 'B', 'both_bad')]), []);
+});
+
+test('bootstrap intervals are deterministic for a seed', () => {
+ const games = Array.from({ length: 30 }, (_, i) => game('A', 'B', i % 3 ? 'a' : 'b'));
+ const options = { bootstrap: 80, seed: 17 };
+ assert.deepEqual(bradleyTerry(games, options), bradleyTerry(games, options));
+ assert.notDeepEqual(bradleyTerry(games, options), bradleyTerry(games, { ...options, seed: 18 }));
+});
+
+test('bootstrap intervals narrow with more games from the same mix', () => {
+ const makeGames = n => Array.from({ length: n }, (_, i) => game('A', 'B', i % 5 < 3 ? 'a' : 'b'));
+ const short = bradleyTerry(makeGames(20), { bootstrap: 250, seed: 7 });
+ const long = bradleyTerry(makeGames(200), { bootstrap: 250, seed: 7 });
+ assert.ok(long[0].ci_upper - long[0].ci_lower < short[0].ci_upper - short[0].ci_lower);
+});
+
+test('weighted games affect the fit and dissenting voters are down-weighted after burn-in', () => {
+ const games = [
+ ...Array.from({ length: 20 }, () => game('A', 'B', 'a', 'agree')),
+ ...Array.from({ length: 20 }, () => game('A', 'B', 'b', 'dissent')),
+ game('A', 'B', 'b', 'new'),
+ ];
+ const fit = [{ subject: 'A', rating: 1100 }, { subject: 'B', rating: 900 }];
+ const weights = voterWeights(games, fit);
+ assert.equal(weights.get('agree'), 1);
+ assert.equal(weights.get('dissent'), 0.25);
+ assert.equal(weights.get('new'), 1);
+ const weighted = bradleyTerry(games, { bootstrap: 0, weights });
+ assert.equal(weighted[0].subject, 'A');
+ assert.ok(bySubject(weighted).get('A').rating > 1000);
+});
+
+test('one-sided and disconnected graphs yield finite ratings and intervals', () => {
+ const fit = bradleyTerry([game('A', 'B', 'a'), game('C', 'D', 'a')], { bootstrap: 30 });
+ assert.equal(fit.length, 4);
+ assert.ok(fit.every(row => Number.isFinite(row.rating) && Number.isFinite(row.ci_lower) && Number.isFinite(row.ci_upper)));
+});
+
+test('Wilson interval matches reference values and handles zero trials', () => {
+ assert.deepEqual(wilson(0, 0), { p: 0, lower: 0, upper: 0 });
+ const result = wilson(5, 10);
+ assert.equal(result.p, 0.5);
+ assert.ok(Math.abs(result.lower - 0.23658959361548731) < 1e-12);
+ assert.ok(Math.abs(result.upper - 0.7634104063845126) < 1e-12);
+});
+
+test('Pareto frontier retains tradeoffs and equal rows', () => {
+ const rows = [{ id: 'cheap', cost: 1, score: 4 }, { id: 'best', cost: 3, score: 8 },
+ { id: 'dominated', cost: 4, score: 3 }, { id: 'equal', cost: 1, score: 4 }];
+ const result = pareto(rows, { x: 'cost', y: 'score' });
+ assert.deepEqual(result.map(row => row.frontier), [true, true, false, true]);
+ assert.equal(rows[0].frontier, undefined);
+ assert.deepEqual(pareto([{ x: 1, y: 2 }, { x: 2, y: 3 }], { x: 'x', y: 'y', lowerX: false, higherY: false }).map(row => row.frontier), [true, true]);
+});
+
+test('cost per solved sums costs and counts, with null for no solutions', () => {
+ assert.deepEqual(costPerSolved([{ cost_usd: 2, solved: true }, { cost_usd: 4, solved: false }, { cost_usd: 3, solved: 2 }]),
+ { cost_usd: 9, solved: 3, per_solved: 3 });
+ assert.deepEqual(costPerSolved([]), { cost_usd: 0, solved: 0, per_solved: null });
+});
+
+test('percentile interpolates and handles empty input', () => {
+ assert.equal(percentile([1, 2, 3, 4], 50), 2.5);
+ assert.equal(percentile([1, 2, 3, 4], 0.5), 2.5);
+ assert.ok(Math.abs(percentile([1, 2, 3, 4], 95) - 3.85) < 1e-12);
+ assert.equal(percentile([], 95), null);
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+
+const assert = require('assert');
+const rating = require('../server/rating');
+const { check, done } = require('./helpers/boot');
+
+let failures = 0;
+async function t(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`); }
+}
+
+(async () => {
+ await t('eloUpdate: a win moves rating up and the loser down', () => {
+ const out = rating.eloUpdate(1000, 1000, 'left', 32);
+ assert.ok(out.a > 1000);
+ assert.ok(out.b < 1000);
+ assert.strictEqual(Math.round((out.a - 1000) * 100) / 100, 16);
+ });
+
+ await t('eloUpdate: a tie converges, both_bad leaves both unchanged', () => {
+ const tie = rating.eloUpdate(1000, 1000, 'tie', 32);
+ assert.strictEqual(tie.a, 1000);
+ assert.strictEqual(tie.b, 1000);
+ assert.deepStrictEqual(rating.eloUpdate(1200, 900, 'both_bad'), { a: 1200, b: 900 });
+ });
+
+ await t('wilson: perfect and empty samples have the right bounds', () => {
+ const full = rating.wilson(10, 10);
+ assert.strictEqual(full.p, 1);
+ assert.ok(full.lower > 0.5 && full.lower < 1);
+ assert.ok(full.upper > full.lower);
+ const empty = rating.wilson(0, 0);
+ assert.deepStrictEqual(empty, { p: 0, lower: 0, upper: 0 });
+ });
+
+ await t('pareto: the cheap-and-good frontier is marked', () => {
+ const rows = [
+ { name: 'best', x: 0.9, y: 0.1 },
+ { name: 'dominated', x: 0.5, y: 0.5 },
+ { name: 'cheap-low', x: 0.4, y: 0.05 },
+ ];
+ const out = rating.pareto(rows, { x: 'x', y: 'y' });
+ assert.strictEqual(out.find((r) => r.name === 'best').frontier, true);
+ assert.strictEqual(out.find((r) => r.name === 'dominated').frontier, false);
+ assert.strictEqual(out.find((r) => r.name === 'cheap-low').frontier, true);
+ });
+
+ await t('costPerSolved: sums solved spend only', () => {
+ const entries = [
+ { cost_usd: 0.1, solved: true },
+ { cost_usd: 0.2, solved: true },
+ { cost_usd: 0.3, solved: false },
+ ];
+ assert.strictEqual(rating.costPerSolved(entries), 0.15);
+ assert.strictEqual(rating.costPerSolved([{ cost_usd: 0.5, solved: false }]), null);
+ });
+
+ await t('bradleyTerry: deterministic, sorted, with games and bootstrap intervals', () => {
+ const games = [
+ { a: 'm1', b: 'm2', outcome: 'a' },
+ { a: 'm1', b: 'm2', outcome: 'a' },
+ { a: 'm1', b: 'm2', outcome: 'b' },
+ { a: 'm2', b: 'm3', outcome: 'a' },
+ ];
+ const rows = rating.bradleyTerry(games, { bootstrap: 200, seed: 7 });
+ assert.strictEqual(rows.length, 3);
+ assert.ok(rows[0].rating > rows[1].rating);
+ assert.strictEqual(rows.find((r) => r.subject === 'm1').games, 3);
+ assert.ok(rows.every((r) => Number.isFinite(r.ci_lower) && Number.isFinite(r.ci_upper)));
+ const again = rating.bradleyTerry(games, { bootstrap: 200, seed: 7 });
+ assert.deepStrictEqual(rows, again);
+ });
+
+ if (failures) { process.stdout.write(`\n${failures} failed\n`); process.exit(1); }
+ process.stdout.write('\nall passed\n');
+ process.exit(0);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+#!/usr/bin/env node
+/**
+ * Runs every test/*.test.js in its own process and fails if any fails. They use temp PGlite databases, an
+ * in-process mock of OpenVibe.Network and a fake AI; none needs the network or a paid API.
+ */
+'use strict';
+const { run } = require('openvibe-shared/test-runner');
+
+run({ dir: __dirname, timeoutMs: 90000, pad: 34, parallel: 1 }).then((r) => {
+ if (process.env.VERBOSE) {
+ for (const t of r.results) console.log(`\n── ${t.file} ──\n${t.output.trimEnd()}`);
+ console.log(`\n${r.summary}`);
+ }
+ process.exit(r.exitCode);
+}, (err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+'use strict';
+
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+
+ await check('a cookie write from another origin is refused', async () => {
+ const r = await t.get('/api/v1/kinds/design.page/tasks', {
+ as: alice,
+ json: { prompt: 'A page for a robot.', public: true },
+ origin: 'https://evil.example',
+ });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json().code, 'bench.cross_site');
+ });
+
+ await check('the same cookie write is allowed from this origin', async () => {
+ const r = await t.get('/api/v1/kinds/design.page/tasks', {
+ as: alice,
+ json: { prompt: 'A page for a robot.', public: true },
+ origin: t.config.baseUrl,
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ });
+
+ await check('a Bearer write is unaffected by the Origin header', async () => {
+ const r = await t.get('/api/v1/kinds/design.page/tasks', {
+ bearer: t.network.userToken(alice),
+ json: { prompt: 'A page for a robot.', public: true },
+ origin: 'https://evil.example',
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ });
+
+ await check('stop', async () => { await t.close(); });
+
+ done();
+})();