openvibers/openvibe.examples Commits
OpenVibe.Examples 0.1.0 (Wave 20): the nine charter examples with smoke tests
Changes
nnew file mode 100644@@@ -0,0 +1,17 @@++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.22.1 # the Node version the platform's services run++ - run: npm ci # one install for every example (npm workspaces)++ # Every example's smoke test runs against openvibe-sdk/testing's mock platform and small local++ # mocks (Chat WebSocket, Tools jobs). No test reaches the network or a real service.++ - run: npm testnnew file mode 100644@@@ -0,0 +1,9 @@++node_modules/++.env++.env.webhook++data/++out/++.job-state.json++*.db++*.db-wal++*.db-shm@@@ -1,661 +1,21 @@-- 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/>.++MIT License++++Copyright (c) 2026 OpenVibers++++Permission is hereby granted, free of charge, to any person obtaining a copy++of this software and associated documentation files (the "Software"), to deal++in the Software without restriction, including without limitation the rights++to use, copy, modify, merge, publish, distribute, sublicense, and/or sell++copies of the Software, and to permit persons to whom the Software is++furnished to do so, subject to the following conditions:++++The above copyright notice and this permission notice shall be included in all++copies or substantial portions of the Software.++++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR++IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,++FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE++AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER++LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,++OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE++SOFTWARE.@@@ -1,69 +1,201 @@# OpenVibe.Examples--> Executable public integration examples, tested in CI against the platform.----**Status:** placeholder — planning only, no runnable code yet.--**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.1 and §18.9.--**License:** AGPL-3.0 (same as every OpenVibe service).----## Purpose----Replaces the effectively empty `powerchat-devapp-demo`. Every example runs against the platform integration environment using only public docs, the SDK and scoped credentials.++> Executable public integration examples for the OpenVibe platform: the SDK, scoped credentials++> and public APIs, nothing else.++++**Status:** alpha (roadmap Wave 20). Nine examples, each with a smoke test that CI runs on Node++22.22.1 against `openvibe-sdk/testing`'s mock platform and two small local mocks. **None of them++runs against the live platform in CI**, and several cannot run against it yet: see++[What works against the real platform today](#what-works-against-the-real-platform-today).++**Built on:** [openvibe-sdk v0.2.2](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.2.2), and++openvibe-contracts v0.26.0 for the mod manifest.++**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.1++and §18.9; roadmap Wave 20, §30 (public SDK surface), ADR-014 (developer projects).++**License:** MIT ([LICENSE](LICENSE)), so you can copy any example into your own project. The++OpenVibe services themselves are AGPL-3.0.++++## The examples++++| Example | What it proves | Uses |++|---|---|---|++| [browser-app](examples/browser-app) | A plain page (no build step) signs in with PKCE and reads a public API from the browser; a public app needs no secret | `openvibe-sdk/auth/browser`, `/registry`, `/core` |++| [node-server-app](examples/node-server-app) | Client-credentials app token per audience, registry discovery, contracts version check, trace propagation | `openvibe-sdk/auth`, `/registry`, `/core` |++| [webhook-consumer](examples/webhook-consumer) | `X-OpenVibe-Signature` verified on the raw body; exactly-once handling with an inbox; secret rotation | `openvibe-sdk/events` (`parseDelivery`, `createInbox`, `subscriptions.create`) |++| [event-subscriber](examples/event-subscriber) | Realtime subscription and pull with a durable cursor; resume after drops and restarts; gap handling | `openvibe-sdk/realtime`, `/events` |++| [media-uploader](examples/media-uploader) | Upload into your project's Media namespace with an app token; content-addressed, safe retries | `openvibe-sdk/media` |++| [chat-bot](examples/chat-bot) | A bot on Chat's WebSocket protocol that stays in its one configured room and always identifies as a bot | Chat `/ws/chat` protocol |++| [tool-job](examples/tool-job) | An OpenVibe.Tools job submitted once (idempotency key), reattached after a restart, SSE resumed with `Last-Event-ID` | Tools `/api/v1/jobs`, `openvibe-sdk/core` |++| [mod-manifest](examples/mod-manifest) | A `mods.mod-manifest@1` validated against the schema, the capability catalog and compatibility ranges | `openvibe-contracts`, `openvibe-sdk/registry` |++| [oauth-app](examples/oauth-app) | Authorization code + PKCE with the exchange on the server; offline token verification; session hygiene | `openvibe-sdk/auth` |++++Each example folder has a README, a `.env.example`, its own `package.json` and a++`test/smoke.test.js`. Configuration is environment variables only (`OV_CLIENT_ID`,++`OV_CLIENT_SECRET`, `OV_NETWORK_URL`, …); secrets are never logged or sent to a browser.++++## Running the tests++++```bash++fnm use 22.22.1 # or any Node 22++npm ci # one install for all examples (npm workspaces)++npm test # every smoke test + the repository checks++npm test -- chat oauth # only some++cd examples/tool-job && npm test++```++++How the tests stay offline:++++- Network, Events and Media are `createMockPlatform()` from `openvibe-sdk/testing`: an in-process++ fake at the real public origins, with real RS256 tokens and the same audience, capability and++ namespace checks.++- Chat and Tools have no SDK mock, so their examples carry a small local mock in their `test/`++ folder (`mock-chat-server.js`, `mock-tools-server.js`) that plays the documented protocol.++- Every smoke test replaces the global `fetch` with one that throws, so an accidental real network++ call fails the test.++++Repository checks (`test/`):++++- `public-surface.test.js` fails on the loopback shared-key header, on the platform's internal++ route prefix, on hard-coded loopback service ports outside the local mocks, and on any secret++ value in a `.env.example`.++- `structure.test.js` checks the nine examples are complete and copyable: README with "What it++ proves" and "Run it against the real platform", a `.env.example` that names every variable the++ code reads, MIT license, `openvibe-sdk` pinned to the v0.2.2 tag, no `file:` links.++++## End-to-end walkthrough++++From a new account to running the examples, with public surfaces only. OpenVibe.Codes (the++developer portal) will be the UI for these steps; it has not launched, so this uses the++developer-projects API on OpenVibe.Network directly. Every call is documented in Network's++[developer projects](https://github.com/OpenVibers/OpenVibe.Network/blob/main/docs/developer-projects.md) doc.++++**1. Create an account** at <https://openvibe.network> (or `POST /api/auth/register`).++++**2. Get a user access token** for the projects API (it reads no cookies). This reads your++password without echoing it or putting it on a command line:++++```bash++NET=https://openvibe.network++read -r -p 'username: ' OV_USER; read -r -s -p 'password: ' OV_PASS; echo; export OV_USER OV_PASS++TOKEN=$(node -e 'process.stdout.write(JSON.stringify({ username: process.env.OV_USER, password: process.env.OV_PASS }))' \++ | curl -s "$NET/api/auth/login" -H 'Content-Type: application/json' --data-binary @- \++ | node -pe 'JSON.parse(require("fs").readFileSync(0)).token')++unset OV_PASS++auth=(-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json')++```++++**3. See what can be granted** (only `public`, `active` capabilities; `first-party` and `internal` never are):++++```bash++curl -s "$NET/api/v1/projects/catalog" "${auth[@]}"++```++++**4. Create a project.** You are its owner. A new project is `sandbox` only.++++```bash++PRJ=$(curl -s -X POST "$NET/api/v1/projects" "${auth[@]}" -d '{"name":"My first OpenVibe app"}' | node -pe 'JSON.parse(require("fs").readFileSync(0)).id')++```++++**5. Create a sandbox app.** A confidential app gets a client secret, shown **once**; this writes++it straight into an example's `.env` instead of the terminal:++++```bash++cd examples/node-server-app++curl -s -X POST "$NET/api/v1/projects/$PRJ/apps" "${auth[@]}" \++ -d '{"name":"examples","environment":"sandbox","type":"confidential","redirect_uris":["http://localhost:3009/callback"]}' \++ | node -e 'const a = JSON.parse(require("fs").readFileSync(0)); require("fs").writeFileSync(".env", `OV_CLIENT_ID=${a.client_id}\nOV_CLIENT_SECRET=${a.credential.client_secret}\n`, { mode: 0o600 }); console.log(a.client_id)'++```++++For [browser-app](examples/browser-app) create a second app with `"type":"public"` and the++redirect URI `http://localhost:3001/callback` (no secret).++++**6. Request grants.** One call per capability; the audience is the owning service++(`openvibe.media` for `media.*`). As the project owner, a request inside the project's allowance is++approved at once; a developer's request waits for an owner or admin++(`POST …/grants/<capability>/approve`).++++```bash++APP=app_... # the client id printed above++for c in media.object.upload media.object.read tools.job.create tools.job.read; do++ curl -s -X POST "$NET/api/v1/projects/$PRJ/apps/$APP/grants" "${auth[@]}" -d "{\"capability\":\"$c\"}"; echo++done++```++++**7. Run the examples.** Start with [node-server-app](examples/node-server-app): its `/status`++shows the services the registry knows and, per audience, whether Network will give your app a++token and which capabilities it carries. Then follow each example's "Run it against the real++platform" section.++++Rotate a secret with `POST …/apps/$APP/credentials/rotate`, revoke one with++`POST …/credentials/<id>/revoke`, revoke the app with `DELETE …/apps/$APP`. Issued tokens end++within their 5-minute lifetime.++++## What works against the real platform today++++These are the platform-side conditions as of this release, read from the services' code and docs.++Nothing here was run against production.++++| Example | Today |++|---|---|++| mod-manifest | Works: validation is offline; `--registry` reads the public registry. Publishing a mod is not public yet. |++| node-server-app | Discovery and registry work for anyone. App tokens: see "Tokens" below. |++| chat-bot | Should work on openvibe.live chat with a Live API token (`hbt_…`, scope `chat`) from a dedicated bot account. Not a developer-app credential: Chat has no app principal, and `chat.message.send` is `first-party`. |++| tool-job | Tools accepts app tokens on `/api/v1/jobs`. Needs a token for `openvibe.tools` (see "Tokens"). |++| media-uploader | Needs a token for `openvibe.media` (see "Tokens") **and** a Media tenant named after your project id, created by the Media operators. |++| oauth-app, browser-app | Sign-in needs a token for the requested audience (see "Tokens"); production apps need an https redirect URI. The browser cannot read the registry cross-origin yet (Network's CORS allow-list runs first), so browser-app falls back to its own server and says so. |++| event-subscriber | Realtime works anonymously for `public` events. Pull cannot work: `events.event.read` is `internal` and the pull API is not on Events' public host. |++| webhook-consumer | Cannot be demonstrated: `events.subscription.manage` is `internal`, the subscription API is not on Events' public host, and deliveries only go to allow-listed hosts. The consumer itself is ready. |++++**Tokens.** Two defaults on Network block every app token until staff change them:++++- `DEV_SANDBOX_AUDIENCES` is empty, so a **sandbox** app gets `400 invalid_target` for every++ audience, and receivers built on openvibe-contracts ≥ 0.26 also refuse `env: sandbox` tokens++ unless they opt in. A **production** app avoids this, but only after staff switch the project to++ `sandbox+production`.++- `DEV_DEFAULT_ALLOWANCE` is empty, so a grant request stays unapproved (`403 grant.beyond_allowance`++ on approval) until staff put the capability in your project's allowance.++++Capabilities a developer app can hold today (`public` + `active` in openvibe-contracts v0.26.0):++`media.object.upload`, `media.object.read`, `tools.job.create`, `tools.job.read`,++`tools.job.cancel`, `games.mod.read`, `games.world.announce`, `games.prop.place`. No `events.*`,++`chat.*` or identity capability is grantable.++++So the Wave 20 exit criterion ("a new external developer goes from account creation to a working++Media, event and capability integration using only public documentation, the SDK and scoped++credentials") is **not met on the live platform yet**: Media needs staff actions (sandbox++audience or production policy, allowance, Media tenant), and the event integration has no public++path except anonymous realtime.++++## Layout++++```++examples/<name>/ one example: README.md, .env.example, package.json, code, test/smoke.test.js++test/run.js runs every smoke test and the repository checks (npm test)++test/public-surface.test.js, test/structure.test.js++.github/workflows/ci.yml Node 22.22.1: npm ci && npm test++```## Owns--- vanilla browser app, Node server app, webhook consumer, event subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example++- the nine charter examples: vanilla browser app, Node server app, webhook consumer, event++ subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example## Does not own--- any platform service----## Planned surfaces----- one directory per example with a README and a smoke test----## Data (authority tables / families)----- none----## Capabilities and events----- consumes public capabilities only----Events: n/a++- any platform service, the SDK (OpenVibe.SDK) or the contracts (OpenVibe.Contracts)## Depends on--- OpenVibe.SDK--- OpenVibe.Contracts--- the platform integration environment (Network, Events, Media, Chat, Community, Live adapters)----## Acceptance (must be true before "done")----- CI runs every example against the integration environment--- no example needs a loopback-only internal key or first-party database access----## Bootstrap / extraction source----New; supersedes powerchat-devapp-demo.----## Launch rule----This repository does not make the product real, and the domain keeps its placeholder page on--[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the--following exist here (plan §12.12):++- OpenVibe.SDK (v0.2.2), OpenVibe.Contracts (v0.26.0, mod manifest only)++- at run time against the real platform: Network (tokens, registry, developer projects), Events++ (realtime), Media, Tools, Chat (on openvibe.live)--1. an owning runtime with health/readiness endpoints and observability;--2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);--3. server-rendered or static public routes that are useful without JavaScript;--4. real persistence and end-to-end workflows;--5. capability and event registration against `OpenVibe.Contracts`;--6. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;--7. acceptance tests proving the advertised functionality.++## Acceptance--The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and--registers maturity in the ecosystem registry atomically. A placeholder is never counted as an--implemented service.++- CI runs every example: **yes**, against the SDK's mock platform and local mocks (no network).++ Running them in CI against an integration environment needs sandbox-enabled audiences and a++ CI project with grants; that environment does not exist yet.++- No example needs a loopback-only internal key or first-party database access: **yes**, enforced++ by `test/public-surface.test.js`.---@@@ -1,7 +1,12 @@{"repository": "OpenVibers/OpenVibe.Examples",-- "stage": "placeholder",-- "code": false,-- "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",-- "updated": "2026-09-21"++ "stage": "alpha",++ "code": true,++ "wave": "W20",++ "version": "0.1.0",++ "sdk": "openvibe-sdk v0.2.2",++ "contracts": "openvibe-contracts v0.26.0 (mod-manifest example)",++ "ci": "every example's smoke test runs on Node 22.22.1 against openvibe-sdk/testing's mock platform and local mocks; none runs against the live platform",++ "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.1, §18.9; roadmap Wave 20, §30, ADR-014",++ "updated": "2026-09-22"}nnew file mode 100644@@@ -0,0 +1,10 @@++# A PUBLIC developer app (type "public": no client secret) in your OpenVibe project.++# Register the redirect URI below on the app exactly as written.++OV_NETWORK_URL=https://openvibe.network++OV_CLIENT_ID=++# Sign-in asks for capabilities of ONE audience; the app needs an approved grant for each.++OV_AUDIENCE=openvibe.media++OV_SCOPE=media.object.read++OV_REDIRECT_URI=http://localhost:3001/callback++# Defaults to the redirect URI's port.++OV_PORT=3001nnew file mode 100644@@@ -0,0 +1,65 @@++# Vanilla browser app++++A plain HTML page, no framework and no build step, that signs in with OpenVibe using PKCE++(`openvibe-sdk/auth/browser`) and reads a public API (the ecosystem registry) straight from the browser.++++```bash++node --env-file=.env server.js # open http://localhost:3001++```++++## What it proves++++- **PKCE in the browser.** The page calls `startAuthorization()`; the verifier and `state` live in++ `sessionStorage` until Network redirects back to `/callback`, where `readCallback()` checks++ `state` and the address bar is cleaned. The verifier is used once.++- **A public app has no secret.** The app is registered as `public`. Its tiny server exchanges++ code + verifier at Network's token endpoint (no secret; `audience` is sent) and keeps the++ resulting token in an HttpOnly session. It holds no secrets at all. The exchange endpoint only++ accepts same-origin JSON requests (`Origin` check, `415` otherwise), so another site cannot drive it.++- **A public API from the page.** `openvibe-sdk/registry` lists the network's services with no++ token and no cookie. The DOM is built with `textContent`, never `innerHTML`, under a strict CSP.++- **The SDK in a page without a bundler.** `openvibe-sdk` is CommonJS, so `lib/sdk-bundle.js`++ serves its browser-safe modules (`core`, `auth/browser`, `registry`) as one script that sets++ `window.OpenVibeSDK`. It refuses to include anything that is not a relative SDK module, and the++ test checks the served script contains no server-only code. With a bundler, import++ `openvibe-sdk/auth/browser` directly instead.++++## Files++++| File | What |++|---|---|++| `public/index.html`, `public/app.js` | the page |++| `server.js` | static files, `/sdk/openvibe-sdk.js`, `/config.json`, `/auth/exchange`, `/api/session`, `/api/registry/services`, `/auth/logout` |++| `lib/sdk-bundle.js` | the serve-time wrapper for the SDK's browser modules |++| `test/smoke.test.js` | runs `public/app.js` and the served bundle in a small fake browser (vm) against the server and the SDK mock |++++## Run the smoke test++++```bash++npm test++```++++There is no real browser in CI: the test gives the page's own script a minimal DOM,++`sessionStorage`, `location`, a cookie jar and `fetch`, clicks "Sign in", follows the redirect with++a code from `platform.authorize()` (which stands in for Network's consent page and verifies PKCE),++and checks the signed-in state, a forged callback, the registry read and its fallback.++++## Run it against the real platform++++1. Create a project and a **public** app with the redirect URI `http://localhost:3001/callback`++ ([walkthrough](../../README.md#end-to-end-walkthrough)); request `media.object.read` (or any++ grant for the audience you put in `OV_AUDIENCE`).++2. `cp .env.example .env`, set `OV_CLIENT_ID`.++3. `node --env-file=.env server.js`, open `http://localhost:3001`, sign in as a project member.++++What does not work yet on the real platform:++++- **The registry refuses third-party browser origins.** Network answers the registry with++ `Access-Control-Allow-Origin: *`, but its site-wide CORS allow-list rejects unknown origins++ first, and SDK calls carry `traceparent` / `X-OpenVibe-Request-Id` headers, which need a++ preflight. The page therefore falls back to reading the same public registry through its own++ server (`/api/registry/services`) and says so on the page.++- **Sign-in** needs Network to mint a token for `OV_AUDIENCE`: a sandbox app gets `invalid_target`++ until staff list that audience in `DEV_SANDBOX_AUDIENCES`; a production app needs an https++ redirect URI. Sign-in always asks for a capability: there is no identity-only sign-in for++ apps, and an app learns only the person's `usr_…` id (profile lookups are `first-party`).nnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++/**++ * Serve openvibe-sdk's browser-safe modules to a page with no build step.++ *++ * openvibe-sdk 0.2.2 is CommonJS, and its ESM entry points import CommonJS files, so a browser++ * cannot load it directly without a bundler. This file wraps the modules a sign-in page needs++ * (core, auth/browser, registry) and everything they require into one script that sets++ * `window.OpenVibeSDK = { core, auth, registry }`. It refuses anything that is not a relative++ * require inside the SDK (no `node:` modules, no packages), so server-only code can never slip in.++ *++ * If you use a bundler (esbuild, Vite, webpack), import 'openvibe-sdk/auth/browser' etc. instead.++ */++const fs = require('node:fs');++const path = require('node:path');++const crypto = require('node:crypto');++++const ENTRIES = { core: 'src/core/index.js', auth: 'src/auth/browser.js', registry: 'src/registry.js' };++const REQUIRE_RE = /require\(\s*'([^']+)'\s*\)/g;++++function buildSdkBundle(root = path.dirname(require.resolve('openvibe-sdk/package.json'))) {++ const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;++ const modules = new Map();++ const visit = (id) => {++ if (modules.has(id)) return;++ const source = fs.readFileSync(path.join(root, id), 'utf8');++ modules.set(id, null);++ const deps = {};++ for (const [, spec] of source.matchAll(REQUIRE_RE)) {++ if (!spec.startsWith('.')) throw new Error(`${id} requires "${spec}": not browser-safe, refusing to bundle`);++ let dep = path.posix.normalize(path.posix.join(path.posix.dirname(id), spec));++ if (!dep.endsWith('.js')) dep += '.js';++ deps[spec] = dep;++ visit(dep);++ }++ if (/process\.env|client_secret/.test(source)) throw new Error(`${id} references server-only material`);++ modules.set(id, { source, deps });++ };++ for (const id of Object.values(ENTRIES)) visit(id);++++ const defs = [...modules].map(([id, { source, deps }]) => ` ${JSON.stringify(id)}: [${JSON.stringify(deps)}, function (module, exports, require) {\n${source}\n}]`).join(',\n');++ const code = `/* openvibe-sdk ${version}: core, auth/browser, registry (MIT). Generated at serve time by examples/browser-app/lib/sdk-bundle.js. */++(function (global) {++ 'use strict';++ var defs = {++${defs}++ };++ var cache = {};++ function load(id) {++ if (cache[id]) return cache[id].exports;++ var def = defs[id];++ if (!def) throw new Error('openvibe-sdk bundle: no module ' + id);++ var module = cache[id] = { exports: {} };++ def[1](module, module.exports, function (spec) { return load(def[0][spec]); });++ return module.exports;++ }++ global.OpenVibeSDK = { version: ${JSON.stringify(version)}, ${Object.entries(ENTRIES).map(([k, id]) => `${k}: load(${JSON.stringify(id)})`).join(', ')} };++})(typeof window !== 'undefined' ? window : globalThis);++`;++ return { code, version, etag: `"${crypto.createHash('sha256').update(code).digest('base64url').slice(0, 27)}"`, modules: [...modules.keys()] };++}++++module.exports = { buildSdkBundle, ENTRIES };nnew file mode 100644@@@ -0,0 +1,18 @@++{++ "name": "openvibe-example-browser-app",++ "version": "0.1.0",++ "private": true,++ "description": "Vanilla browser app: sign in with OpenVibe.Network (authorization code + PKCE) and call a public API from the browser.",++ "license": "MIT",++ "main": "server.js",++ "scripts": {++ "start": "node --env-file-if-exists=.env server.js",++ "test": "node test/smoke.test.js"++ },++ "engines": {++ "node": ">=22"++ },++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ }++}nnew file mode 100644@@@ -0,0 +1,98 @@++/* OpenVibe browser app example: sign in with PKCE, then read the public registry.++ * Plain browser JavaScript, no framework and no build step. window.OpenVibeSDK comes from++ * /sdk/openvibe-sdk.js (openvibe-sdk's core, auth/browser and registry modules). DOM nodes are++ * built with textContent, never innerHTML. */++(function () {++ 'use strict';++ var sdk = window.OpenVibeSDK;++ var PKCE_KEY = 'ov_example_pkce';++ var $ = function (id) { return document.getElementById(id); };++++ function showError(err) {++ var el = $('auth-error');++ el.textContent = 'Sign-in failed: ' + ((err && (err.code || err.message)) || 'unknown error');++ el.hidden = false;++ }++++ function getJson(url, init) {++ return fetch(url, Object.assign({ credentials: 'same-origin', headers: { Accept: 'application/json' } }, init || {}))++ .then(function (res) { return res.json().then(function (body) { return { status: res.status, body: body }; }); });++ }++++ /** Back from Network on /callback: check state, hand code + verifier to this app's server. */++ function finishSignIn() {++ var href = location.href;++ var saved = null;++ try { saved = JSON.parse(sessionStorage.getItem(PKCE_KEY) || 'null'); } catch (e) { saved = null; }++ sessionStorage.removeItem(PKCE_KEY);++ history.replaceState(null, '', '/');++ var callback;++ try {++ if (!saved) throw { code: 'oauth.no_pending_sign_in' };++ callback = sdk.auth.readCallback(href, { expectedState: saved.state });++ } catch (err) { showError(err); return Promise.resolve(); }++ return getJson('/auth/exchange', {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },++ body: JSON.stringify({ code: callback.code, codeVerifier: saved.codeVerifier }),++ }).then(function (r) { if (r.status !== 200) showError({ code: r.body.error }); });++ }++++ function startSignIn(config) {++ return sdk.auth.startAuthorization({ network: config.network, clientId: config.clientId, redirectUri: config.redirectUri, scope: config.scope })++ .then(function (a) {++ sessionStorage.setItem(PKCE_KEY, JSON.stringify({ state: a.state, codeVerifier: a.codeVerifier }));++ location.assign(a.url);++ });++ }++++ function showSession() {++ return getJson('/api/session').then(function (r) {++ var signedIn = r.status === 200;++ $('who').textContent = signedIn++ ? 'Signed in with OpenVibe as ' + r.body.subject + '. This app may: ' + (r.body.capabilities.join(', ') || 'nothing') + '.'++ : 'Not signed in.';++ $('sign-in').hidden = signedIn;++ $('sign-out').hidden = !signedIn;++ });++ }++++ function renderServices(services, via) {++ var list = $('services');++ list.textContent = '';++ services.forEach(function (s) {++ var li = document.createElement('li');++ var name = document.createElement('code');++ name.textContent = s.id;++ li.appendChild(name);++ li.appendChild(document.createTextNode(' ' + s.status + (s.publicOrigin ? ' - ' + s.publicOrigin : '')));++ list.appendChild(li);++ });++ $('registry-status').textContent = services.length + ' services, ' + via + '.';++ }++++ /** A public API straight from the browser: no token, no cookie. */++ function loadRegistry(config) {++ var client = sdk.core.createClient({ network: config.network, retries: 1 });++ return sdk.registry.createRegistryClient(client).services()++ .then(function (services) { renderServices(services, 'read by this page from ' + config.network); })++ .catch(function () {++ // The browser refused the cross-origin call (CORS). Read it through this app's server.++ return getJson('/api/registry/services').then(function (r) {++ if (r.status !== 200) throw new Error(r.body.error);++ renderServices(r.body.services, 'read through this app\'s server (the registry does not allow this origin from a browser yet)');++ });++ })++ .catch(function (err) { $('registry-status').textContent = 'Could not read the registry: ' + (err.code || err.message); });++ }++++ getJson('/config.json').then(function (r) {++ var config = r.body;++ $('sign-in').addEventListener('click', function () { startSignIn(config).catch(showError); });++ $('sign-out').addEventListener('click', function () {++ getJson('/auth/logout', { method: 'POST' }).then(showSession);++ });++ var done = location.pathname === new URL(config.redirectUri).pathname ? finishSignIn() : Promise.resolve();++ return done.then(showSession).then(function () { return loadRegistry(config); });++ });++}());nnew file mode 100644@@@ -0,0 +1,37 @@++<!doctype html>++<html lang="en">++<head>++ <meta charset="utf-8">++ <meta name="viewport" content="width=device-width, initial-scale=1">++ <title>OpenVibe browser app example</title>++ <style>++ body { font: 16px/1.5 system-ui, sans-serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem; color: #1b1b1f; background: #fff; }++ @media (prefers-color-scheme: dark) { body { color: #e7e7ea; background: #131316; } }++ code { font-size: 0.9em; }++ .error { color: #c62828; }++ ul { padding-left: 1.2rem; }++ </style>++</head>++<body>++ <h1>OpenVibe browser app example</h1>++ <p>Sign in with OpenVibe (authorization code + PKCE), then read the public ecosystem registry from this page.</p>++++ <section>++ <h2>Account</h2>++ <p id="who">Checking your session...</p>++ <button id="sign-in" hidden>Sign in with OpenVibe</button>++ <button id="sign-out" hidden>Sign out</button>++ <p id="auth-error" class="error" hidden></p>++ </section>++++ <section>++ <h2>Services on the network</h2>++ <p id="registry-status">Loading the registry...</p>++ <ul id="services"></ul>++ </section>++++ <noscript><p>This example needs JavaScript: it shows how a browser signs in with the SDK.</p></noscript>++ <script src="/sdk/openvibe-sdk.js"></script>++ <script src="/app.js"></script>++</body>++</html>nnew file mode 100644@@@ -0,0 +1,167 @@++#!/usr/bin/env node++'use strict';++/**++ * Vanilla browser app: a static page that signs in with OpenVibe.Network using PKCE++ * (openvibe-sdk/auth/browser in the browser) and calls a public API (the ecosystem registry)++ * straight from the browser.++ *++ * node --env-file=.env server.js # http://localhost:3001++ *++ * The app is a PUBLIC developer app: it has no client secret. The browser makes the PKCE pair and++ * keeps the verifier in sessionStorage; after the redirect it hands code + verifier to this tiny++ * server, which exchanges them at Network's token endpoint (Network does not open its token++ * endpoint to third-party origins, and the resulting token is kept out of page scripts in an++ * HttpOnly session). The server has no secrets at all.++ *++ * GET /, /callback the page (public/index.html)++ * GET /app.js the page's script++ * GET /sdk/openvibe-sdk.js openvibe-sdk's browser-safe modules as one script (lib/sdk-bundle.js)++ * GET /config.json public settings: network, client id, redirect URI, scope++ * POST /auth/exchange { code, codeVerifier } -> session cookie, { subject }++ * GET /api/session who is signed in++ * GET /api/registry/services the same public registry read, server side: the page's fallback++ * while Network does not allow third-party origins on the registry++ * POST /auth/logout++ */++const crypto = require('node:crypto');++const fs = require('node:fs');++const http = require('node:http');++const path = require('node:path');++const { createClient, isOpenVibeError } = require('openvibe-sdk/core');++const { verifyUserToken } = require('openvibe-sdk/auth');++const { createRegistryClient } = require('openvibe-sdk/registry');++const { buildSdkBundle } = require('./lib/sdk-bundle');++++const SESSION_COOKIE = 'ov_browser_example_sid';++const PUBLIC = path.join(__dirname, 'public');++++function loadConfig(env = process.env) {++ const missing = ['OV_CLIENT_ID', 'OV_AUDIENCE', 'OV_SCOPE'].filter((k) => !env[k]);++ if (missing.length) throw Object.assign(new Error(`missing environment variables: ${missing.join(', ')} (see .env.example)`), { code: 'config.missing' });++ const redirectUri = env.OV_REDIRECT_URI || 'http://localhost:3001/callback';++ return {++ network: env.OV_NETWORK_URL || 'https://openvibe.network',++ clientId: env.OV_CLIENT_ID,++ audience: env.OV_AUDIENCE,++ scope: env.OV_SCOPE.split(/[\s,]+/).filter(Boolean),++ redirectUri,++ origin: new URL(redirectUri).origin,++ port: Number(env.OV_PORT || new URL(redirectUri).port || 3001),++ };++}++++/** Network's token endpoint, authorization_code for a public app (no secret; `audience` is required). */++async function exchangePublicCode(client, { clientId, code, codeVerifier, redirectUri, audience, scope }) {++ return client.json({++ service: 'network', path: '/oauth/token', method: 'POST', auth: false, retries: 0, idempotencyKey: false,++ urlencoded: { grant_type: 'authorization_code', client_id: clientId, code, code_verifier: codeVerifier, redirect_uri: redirectUri, audience, scope: scope.join(' ') },++ });++}++++function createApp(config, { fetch, log = console } = {}) {++ const client = createClient({ network: config.network, fetch });++ const bundle = buildSdkBundle();++ const sessions = new Map(); // sid -> { subject, capabilities, expiresAt }++ const secure = config.redirectUri.startsWith('https://');++ const cookie = (sid, maxAge) => `${SESSION_COOKIE}=${sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure ? '; Secure' : ''}`;++ const sidOf = (req) => {++ const m = String(req.headers.cookie || '').match(new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([A-Za-z0-9_-]+)`));++ return m ? m[1] : null;++ };++ const current = (req) => {++ const s = sessions.get(sidOf(req));++ return s && s.expiresAt > Date.now() ? s : null;++ };++ const publicConfig = JSON.stringify({ network: config.network, clientId: config.clientId, redirectUri: config.redirectUri, scope: config.scope });++++ function json(res, status, body, headers = {}) {++ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers });++ res.end(JSON.stringify(body));++ }++ function file(res, name, type) {++ res.writeHead(200, {++ 'Content-Type': type, 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff',++ 'Content-Security-Policy': `default-src 'self'; connect-src 'self' ${config.network}; img-src 'self' data:; style-src 'self' 'unsafe-inline'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'`,++ });++ res.end(fs.readFileSync(path.join(PUBLIC, name)));++ }++ function readJson(req, limit = 16 * 1024) {++ return new Promise((resolve, reject) => {++ let body = '';++ req.on('data', (c) => { body += c; if (body.length > limit) reject(Object.assign(new Error('too large'), { status: 413 })); });++ req.on('end', () => { try { resolve(JSON.parse(body || '{}')); } catch { reject(Object.assign(new Error('bad json'), { status: 400 })); } });++ });++ }++++ async function exchange(req, res) {++ // Only this page may call this: same Origin, JSON body (a cross-site form cannot send either).++ if (req.headers.origin !== config.origin) return json(res, 403, { error: 'bad_origin' });++ if (!/^application\/json\b/.test(req.headers['content-type'] || '')) return json(res, 415, { error: 'json_required' });++ const { code, codeVerifier } = await readJson(req);++ if (typeof code !== 'string' || !code || typeof codeVerifier !== 'string' || !/^[A-Za-z0-9._~-]{43,128}$/.test(codeVerifier)) {++ return json(res, 400, { error: 'bad_request' });++ }++ try {++ const tokens = await exchangePublicCode(client, { ...config, code, codeVerifier });++ const d = await client.discover();++ const claims = await verifyUserToken(tokens.access_token, {++ jwks: d.jwksUri || `${config.network}/api/.well-known/jwks`, issuer: d.issuer || undefined, audience: config.audience, allowServiceTokens: true, fetch,++ });++ // Developer-app tokens name the person in on_behalf_of and must be for this app.++ const subject = claims.actor_type === 'app' ? (claims.sub === `app:${config.clientId}` ? claims.on_behalf_of : null) : claims.subject_id;++ if (!subject) return json(res, 400, { error: 'no_subject' });++ for (const [k, v] of sessions) if (v.expiresAt <= Date.now()) sessions.delete(k); // in-memory store: clean up++ const sid = crypto.randomBytes(32).toString('base64url');++ sessions.set(sid, { subject, capabilities: claims.cap || [], expiresAt: claims.exp * 1000 });++ log.log(`[browser-app] signed in ${subject}`);++ return json(res, 200, { subject, capabilities: claims.cap || [] }, { 'Set-Cookie': cookie(sid, Math.max(60, claims.exp - Math.floor(Date.now() / 1000))) });++ } catch (err) {++ log.error(`[browser-app] exchange failed: ${isOpenVibeError(err) ? `${err.code} request=${err.requestId}` : err.message}`);++ return json(res, 400, { error: isOpenVibeError(err) ? err.code : 'exchange_failed' });++ }++ }++++ const server = http.createServer(async (req, res) => {++ const url = new URL(req.url, 'http://app.invalid');++ try {++ if (req.method === 'GET' && (url.pathname === '/' || url.pathname === new URL(config.redirectUri).pathname)) return file(res, 'index.html', 'text/html; charset=utf-8');++ if (req.method === 'GET' && url.pathname === '/app.js') return file(res, 'app.js', 'text/javascript; charset=utf-8');++ if (req.method === 'GET' && url.pathname === '/sdk/openvibe-sdk.js') {++ if (req.headers['if-none-match'] === bundle.etag) { res.writeHead(304, { ETag: bundle.etag }); return res.end(); }++ res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-cache', ETag: bundle.etag });++ return res.end(bundle.code);++ }++ if (req.method === 'GET' && url.pathname === '/config.json') {++ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' });++ return res.end(publicConfig);++ }++ if (req.method === 'POST' && url.pathname === '/auth/exchange') return await exchange(req, res);++ if (req.method === 'GET' && url.pathname === '/api/session') {++ const s = current(req);++ return s ? json(res, 200, { subject: s.subject, capabilities: s.capabilities, expires_at: new Date(s.expiresAt).toISOString() }) : json(res, 401, { error: 'not_signed_in' });++ }++ if (req.method === 'GET' && url.pathname === '/api/registry/services') {++ return json(res, 200, { services: await createRegistryClient(client).services() }, { 'Cache-Control': 'public, max-age=30' });++ }++ if (req.method === 'POST' && url.pathname === '/auth/logout') {++ if (req.headers.origin !== config.origin) return json(res, 403, { error: 'bad_origin' });++ sessions.delete(sidOf(req));++ return json(res, 200, { ok: true }, { 'Set-Cookie': cookie('', 0) });++ }++ return json(res, 404, { error: 'not_found' });++ } catch (err) {++ if (!res.headersSent) json(res, err.status || 500, { error: err.status ? err.message : 'error' });++ return undefined;++ }++ });++ return { server, sessions, bundle };++}++++if (require.main === module) {++ let config;++ try { config = loadConfig(); } catch (err) { console.error(err.message); process.exit(2); }++ const { server } = createApp(config);++ server.listen(config.port, () => console.log(`browser-app: ${config.origin} (redirect URI ${config.redirectUri})`));++}++++module.exports = { loadConfig, createApp, exchangePublicCode };nnew file mode 100644@@@ -0,0 +1,174 @@++'use strict';++/**++ * Smoke test: the page's own script (public/app.js) and the served SDK bundle run in a small fake++ * browser (a vm context with a minimal DOM, sessionStorage, location, a cookie jar and fetch),++ * against this app's server and the SDK's mock platform. Covers: sign-in button -> authorize URL++ * with PKCE, the callback -> exchange -> signed-in session, the registry read from the page, and++ * the fallback through the app's server when the browser refuses the cross-origin call.++ */++const assert = require('node:assert/strict');++const fs = require('node:fs');++const path = require('node:path');++const vm = require('node:vm');++const { createMockPlatform } = require('openvibe-sdk/testing');++const { createApp, loadConfig } = require('../server');++++const realFetch = globalThis.fetch;++globalThis.fetch = () => { throw new Error('network access in a smoke test'); };++++const APP = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TA';++++async function waitFor(check, what, ms = 3000) {++ const until = Date.now() + ms;++ while (Date.now() < until) {++ if (check()) return;++ await new Promise((r) => setTimeout(r, 10));++ }++ throw new Error(`timed out waiting for ${what}`);++}++++/** A tiny browser: enough DOM for app.js, and fetch that talks to the app server or the mock. */++function createBrowser({ base, origin, platform, sessionStorage, jar, crossOrigin = 'allow' }) {++ const elements = new Map();++ const el = (id) => {++ if (!elements.has(id)) {++ const node = {++ id, hidden: false, children: [], listeners: {}, _text: '',++ get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); },++ set textContent(v) { this._text = String(v); this.children = []; },++ appendChild(c) { this.children.push(c); return c; },++ addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); },++ click() { for (const fn of this.listeners.click || []) fn({}); },++ };++ elements.set(id, node);++ }++ return elements.get(id);++ };++ const document = {++ getElementById: el,++ createElement: () => el(`anon-${elements.size}`),++ createTextNode: (t) => ({ textContent: String(t) }),++ };++ const location = {++ href: '', pathname: '/', assigned: null,++ assign(u) { this.assigned = u; },++ };++ const setUrl = (u) => { const x = new URL(u, origin); location.href = x.href; location.pathname = x.pathname; };++ async function browserFetch(input, init = {}) {++ const url = new URL(String(input), location.href);++ if (url.origin === origin) {++ const headers = new Headers(init.headers || {});++ if (jar.cookie) headers.set('cookie', jar.cookie);++ if ((init.method || 'GET') !== 'GET') headers.set('origin', origin); // browsers send Origin on POST++ const res = await realFetch(`${base}${url.pathname}${url.search}`, { ...init, headers });++ const set = res.headers.get('set-cookie');++ if (set) jar.cookie = set.split(';')[0].endsWith('=') ? '' : set.split(';')[0];++ return res;++ }++ if (crossOrigin === 'refuse') throw new TypeError('Failed to fetch'); // what a CORS refusal looks like++ return platform.fetch(input, init);++ }++ const context = vm.createContext({++ document, location, sessionStorage, fetch: browserFetch,++ history: { replaceState: (_s, _t, u) => setUrl(u) },++ console, setTimeout, clearTimeout, URL, URLSearchParams, Headers, Request, Response, AbortController,++ TextEncoder, TextDecoder, btoa, atob, crypto: globalThis.crypto,++ });++ context.window = context;++ return { context, el, location, setUrl };++}++++(async () => {++ const platform = createMockPlatform({ users: [{ username: 'ana' }] });++ const ana = [...platform.state.users.values()][0];++ const env = { OV_CLIENT_ID: APP, OV_AUDIENCE: 'openvibe.media', OV_SCOPE: 'media.object.read' };++ const config = loadConfig(env);++ platform.addClient(APP, { secret: null, redirectUris: [config.redirectUri] }); // a public app: no secret++ const logs = [];++ const app = createApp(config, { fetch: platform.fetch, log: { log: (m) => logs.push(m), error: (m) => logs.push(m) } });++ await new Promise((r) => app.server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${app.server.address().port}`;++++ // The served bundle is browser-safe: only SDK modules, nothing server-side.++ const bundleRes = await realFetch(`${base}/sdk/openvibe-sdk.js`);++ assert.equal(bundleRes.status, 200);++ const bundle = await bundleRes.text();++ assert.ok(!/client_secret|process\.env|require\('node:|require\('crypto'\)/.test(bundle));++ assert.deepEqual(app.bundle.modules.sort(), ['src/auth/browser.js', 'src/core/client.js', 'src/core/errors.js', 'src/core/ids.js', 'src/core/index.js', 'src/core/paginate.js', 'src/core/semver.js', 'src/core/trace.js', 'src/registry.js']);++ assert.equal((await realFetch(`${base}/sdk/openvibe-sdk.js`, { headers: { 'if-none-match': bundleRes.headers.get('etag') } })).status, 304);++ const page = await (await realFetch(`${base}/`)).text();++ assert.match(page, /<script src="\/sdk\/openvibe-sdk\.js"><\/script>/);++ const publicConfig = await (await realFetch(`${base}/config.json`)).json();++ assert.deepEqual(Object.keys(publicConfig).sort(), ['clientId', 'network', 'redirectUri', 'scope']);++ const appJs = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8');++++ const storage = new Map();++ const sessionStorage = { getItem: (k) => (storage.has(k) ? storage.get(k) : null), setItem: (k, v) => storage.set(k, String(v)), removeItem: (k) => storage.delete(k) };++ const jar = { cookie: '' };++ const load = (url, opts = {}) => {++ const b = createBrowser({ base, origin: config.origin, platform, sessionStorage, jar, ...opts });++ b.setUrl(url);++ vm.runInContext(bundle, b.context);++ vm.runInContext(appJs, b.context);++ return b;++ };++++ // 1. First visit: not signed in; the registry is read by the page itself (no token, no cookie).++ let b = load('/');++ await waitFor(() => /services, read by this page/.test(b.el('registry-status').textContent), 'the registry read');++ assert.equal(b.el('who').textContent, 'Not signed in.');++ assert.equal(b.el('sign-in').hidden, false);++ assert.ok(b.el('services').children.some((li) => li.textContent.startsWith('media alpha')));++ const registryCall = platform.stats.requests.find((r) => r.url.includes('/api/v1/registry/services'));++ assert.ok(!registryCall.headers.authorization);++++ // 2. Sign in: the page makes the PKCE pair and goes to Network's authorize URL.++ b.el('sign-in').click();++ await waitFor(() => b.location.assigned, 'the redirect to Network');++ const to = new URL(b.location.assigned);++ assert.equal(`${to.origin}${to.pathname}`, 'https://openvibe.network/oauth/authorize');++ assert.equal(to.searchParams.get('client_id'), APP);++ assert.equal(to.searchParams.get('code_challenge_method'), 'S256');++ assert.equal(to.searchParams.get('scope'), 'media.object.read');++ const saved = JSON.parse(storage.get('ov_example_pkce'));++ assert.equal(saved.state, to.searchParams.get('state'));++++ // 3. Network sends the browser back with a code (platform.authorize stands in for the consent page).++ const code = platform.authorize({ clientId: APP, redirectUri: config.redirectUri, subjectId: ana.subject_id, codeChallenge: to.searchParams.get('code_challenge') });++ b = load(`/callback?code=${code}&state=${encodeURIComponent(saved.state)}`);++ await waitFor(() => b.el('who').textContent.startsWith('Signed in'), 'the signed-in session');++ assert.match(b.el('who').textContent, new RegExp(`Signed in with OpenVibe as ${ana.subject_id}`));++ assert.equal(b.location.pathname, '/', 'the code is removed from the address bar');++ assert.equal(storage.has('ov_example_pkce'), false, 'the verifier is used once');++ assert.match(jar.cookie, /^ov_browser_example_sid=/);++ const exchange = platform.stats.requests.filter((r) => r.url.endsWith('/oauth/token'));++ assert.equal(exchange.length, 1);++++ // 4. A callback this browser did not start: refused before any exchange.++ storage.set('ov_example_pkce', JSON.stringify({ state: 'mine', codeVerifier: saved.codeVerifier }));++ b = load('/callback?code=abc&state=forged');++ await waitFor(() => b.el('auth-error').textContent !== '', 'the state error');++ assert.match(b.el('auth-error').textContent, /oauth\.state_mismatch/);++ assert.equal(platform.stats.requests.filter((r) => r.url.endsWith('/oauth/token')).length, 1);++++ // 5. When the browser refuses the cross-origin registry call, the page reads it through its server.++ b = load('/', { crossOrigin: 'refuse' });++ await waitFor(() => /through this app's server/.test(b.el('registry-status').textContent), 'the registry fallback');++ assert.ok(b.el('services').children.length > 0);++++ // 6. The exchange endpoint accepts only this page: same Origin and a JSON body.++ const cross = await realFetch(`${base}/auth/exchange`, { method: 'POST', headers: { origin: 'https://evil.example', 'content-type': 'application/json' }, body: '{}' });++ assert.equal(cross.status, 403);++ const form = await realFetch(`${base}/auth/exchange`, { method: 'POST', headers: { origin: config.origin, 'content-type': 'application/x-www-form-urlencoded' }, body: 'code=x' });++ assert.equal(form.status, 415);++++ // 7. Sign out.++ b = load('/');++ await waitFor(() => b.el('who').textContent.startsWith('Signed in'), 'the session on reload');++ b.el('sign-out').click();++ await waitFor(() => b.el('who').textContent === 'Not signed in.', 'the sign-out');++++ assert.ok(!/eyJ[\w-]+\.[\w-]+\./.test(logs.join('\n')), 'no tokens in logs');++ app.server.close();++ console.log('browser-app: ok');++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,12 @@++# Chat WebSocket (served on openvibe.live until openvibe.chat launches).++OV_CHAT_WS_URL=wss://openvibe.live/ws/chat++# A Live API token (hbt_..., scope "chat") created by a DEDICATED bot account whose name contains "bot".++OV_CHAT_TOKEN=++# Exactly one room: a channel (the streamer's user id) or a stream id.++OV_CHAT_CHANNEL_USER_ID=++OV_CHAT_STREAM_ID=++OV_BOT_PREFIX="[bot] "++OV_BOT_TRIGGER=?++# Who runs the bot (shown by ?about).++OV_BOT_OWNER=++OV_BOT_MIN_INTERVAL_MS=2000nnew file mode 100644@@@ -0,0 +1,77 @@++# Chat bot++++A small bot that answers `?ping`, `?help` and `?about` in **one** chat room, and says it is a bot++every time it speaks.++++```bash++node --env-file=.env bot.js++# [bot] HelperBot joined channel 42++```++++## What it proves++++Chat's WebSocket protocol (`/ws/chat`, as moved from Live into OpenVibe.Chat):++++```text++-> { "type": "join", "token": "<hbt_…>", "channelUserId": 42 } (or "streamId": 1234)++<- { "type": "auth", "authenticated": true, "username": "HelperBot", "core_username": "HelperBot", … }++-> { "type": "chat", "message": "[bot] pong" } posts into the joined room++<- { "type": "chat", "core_username": "ana", "message": "?ping", "channel_user_id": 42, "stream_id": null, … }++<- { "type": "system", "message": "Slow down! …" }++```++++And the rules a well-behaved bot keeps:++++- **One room.** It joins exactly the configured room (channel or stream), on every reconnect, and++ never sends another join. A socket can only post into the room it joined, and the bot only++ answers messages whose room matches its configuration; frames for other rooms or global chat++ are ignored even if they reach it.++- **Says it is a bot.** It refuses to run unless the account's name contains "bot", and every++ line starts with `OV_BOT_PREFIX` (`[bot] ` by default). Because of the prefix nothing it posts++ can start with `/` or `!`, so it can never run a chat command. `?about` says who runs it.++- **Never anonymous.** If the token is not accepted, the server treats the socket as a guest; the++ bot stops instead of chatting as one.++- **No loops, no floods.** It ignores itself and anything starting with the bot prefix, answers++ only known commands, keeps one line of at most 280 characters, paces replies++ (`OV_BOT_MIN_INTERVAL_MS`) and drops rather than queues more than five pending replies.++- **The token stays secret.** It goes in the join frame, never in the URL (URLs end up in access++ logs), and is never logged.++++## Files++++| File | What |++|---|---|++| `bot.js` | `loadConfig()`, `createBot()` (`start`, `stop`, `say`, `on('ready'|'stop')`), `COMMANDS` |++| `test/mock-chat-server.js` | a local `/ws/chat` that plays the frames above (the SDK mock has no Chat) |++| `test/smoke.test.js` | room isolation, bot labelling, loop and command safety, pacing, reconnect, refusals |++++## Run the smoke test++++```bash++npm test++```++++Uses Node 22's built-in `WebSocket` for the bot; `ws` is a dev dependency for the mock server only.++++## Run it against the real platform++++1. Create a **dedicated account** for the bot whose username contains "bot" (for example++ `HelperBot`). Do not use your own account.++2. Signed in as that account on openvibe.live, create an API token with the `chat` scope++ (Dashboard → API Tokens, or `POST https://openvibe.live/api/auth/tokens`; see++ [API tokens](https://openvibe.live/docs/api-tokens)). It is shown once.++3. `cp .env.example .env`, set `OV_CHAT_TOKEN`, and **one** of `OV_CHAT_CHANNEL_USER_ID` (the++ streamer's user id: the channel room, live or offline) or `OV_CHAT_STREAM_ID`.++4. `node --env-file=.env bot.js`, then type `?ping` in that channel's chat.++++Only use it in a channel whose streamer agreed to have it there; they can ban it like anyone else.++++Limits today:++++- **Not an app credential.** Chat has no bot or app principal: the bot signs in with a Live API++ token, which is a scoped credential of a person's account, not a Network developer-app token.++ `chat.message.send` is a `first-party` capability, so a developer app cannot be granted it.++- **No bot flag in the protocol.** The `[bot]` prefix and the account name are the only way the++ bot can identify itself; chat clients cannot mark bots differently yet.++- Chat is still served on `openvibe.live` (`openvibe.chat` has not launched). The Live API-token++ doc shows the token and stream in the URL query; this example uses the join frame instead.nnew file mode 100644@@@ -0,0 +1,188 @@++#!/usr/bin/env node++'use strict';++/**++ * Chat bot: a small, clearly labelled bot that answers commands in ONE chat room.++ *++ * node --env-file=.env bot.js++ *++ * Protocol (OpenVibe.Chat's /ws/chat, served on openvibe.live until openvibe.chat launches):++ * -> { type: 'join', token, channelUserId } or { type: 'join', token, streamId }++ * <- { type: 'auth', authenticated, username, core_username, role, user_id, ... }++ * -> { type: 'chat', message } posts into the room this socket joined++ * <- { type: 'chat', username, core_username, message, stream_id, channel_user_id, is_global, ... }++ * <- { type: 'system', message } slow mode, bans, ...++ *++ * Rules this bot keeps, whatever it receives:++ * - It joins exactly the room in its configuration, on every (re)connect, and never another.++ * A socket posts only into the room it joined, and the bot only answers messages whose room++ * matches its configuration.++ * - It identifies as a bot: it refuses to run unless the account's name says "bot", and every++ * line it posts starts with OV_BOT_PREFIX (default "[bot] "), which also means nothing it++ * posts can ever start with "/" or "!" and be run as a chat command.++ * - It never runs anonymously: if the token is not accepted it stops instead of chatting as a guest.++ * - It never answers itself or other bots (lines starting with the prefix), and it paces its++ * replies (OV_BOT_MIN_INTERVAL_MS) below the server's slow mode.++ * - The token is sent in the join frame, never in the URL, and never logged.++ *++ * Credentials: a Live API token (hbt_…, scope `chat`) created by a DEDICATED bot account on its++ * dashboard (see docs on openvibe.live/docs/api-tokens). Chat has no app-principal bot login yet.++ */++++const COMMANDS = {++ ping: () => 'pong',++ help: (bot) => `commands: ${Object.keys(COMMANDS).map((c) => `${bot.config.trigger}${c}`).join(', ')}`,++ about: (bot) => `I am an automated bot${bot.config.owner ? ` run by ${bot.config.owner}` : ''}. I only talk in this room and only answer ${bot.config.trigger}commands.`,++};++++function loadConfig(env = process.env) {++ const missing = ['OV_CHAT_TOKEN'].filter((k) => !env[k]);++ if (!env.OV_CHAT_CHANNEL_USER_ID === !env.OV_CHAT_STREAM_ID) missing.push('exactly one of OV_CHAT_CHANNEL_USER_ID / OV_CHAT_STREAM_ID');++ if (missing.length) throw Object.assign(new Error(`missing configuration: ${missing.join(', ')} (see .env.example)`), { code: 'config.missing' });++ const room = env.OV_CHAT_CHANNEL_USER_ID++ ? { kind: 'channel', id: Number(env.OV_CHAT_CHANNEL_USER_ID) }++ : { kind: 'stream', id: Number(env.OV_CHAT_STREAM_ID) };++ if (!Number.isInteger(room.id) || room.id <= 0) throw Object.assign(new Error('the room id must be a positive integer'), { code: 'config.invalid' });++ const prefix = env.OV_BOT_PREFIX === undefined ? '[bot] ' : env.OV_BOT_PREFIX;++ if (!prefix.trim() || /^[/!]/.test(prefix)) throw Object.assign(new Error('OV_BOT_PREFIX must be visible text and must not start with / or !'), { code: 'config.invalid' });++ const trigger = env.OV_BOT_TRIGGER || '?';++ if (/^[/!]/.test(trigger)) throw Object.assign(new Error('OV_BOT_TRIGGER must not start with / or ! (the server runs those as commands)'), { code: 'config.invalid' });++ return {++ wsUrl: env.OV_CHAT_WS_URL || 'wss://openvibe.live/ws/chat',++ token: env.OV_CHAT_TOKEN,++ room,++ prefix,++ trigger,++ owner: env.OV_BOT_OWNER || '',++ minIntervalMs: Number(env.OV_BOT_MIN_INTERVAL_MS || 2000),++ maxLength: 280,++ };++}++++function createBot(config, { WebSocket = globalThis.WebSocket, log = console } = {}) {++ if (typeof WebSocket !== 'function') throw new TypeError('no WebSocket implementation (Node 22+ has one built in)');++ const joinFrame = Object.freeze(config.room.kind === 'channel'++ ? { type: 'join', token: config.token, channelUserId: config.room.id }++ : { type: 'join', token: config.token, streamId: config.room.id });++ const state = { ws: null, ready: false, me: null, stopped: false, failure: null, backoffMs: 1000, queue: [], lastSentAt: 0, timer: null };++ const listeners = { ready: [], stop: [] };++ const emit = (name, arg) => { for (const fn of listeners[name]) fn(arg); };++++ /** Is this chat frame from the room the bot is configured for? */++ function inMyRoom(msg) {++ if (msg.is_global) return false;++ if (config.room.kind === 'stream') return Number(msg.stream_id) === config.room.id;++ return Number(msg.channel_user_id) === config.room.id;++ }++++ /** One line, no control characters, labelled, bounded. */++ function format(text) {++ const clean = String(text).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();++ const room = config.maxLength - config.prefix.length;++ return `${config.prefix}${clean.length > room ? `${clean.slice(0, room - 1)}…` : clean}`;++ }++++ function flush() {++ clearTimeout(state.timer);++ state.timer = null;++ if (!state.queue.length || !state.ready || !state.ws || state.ws.readyState !== WebSocket.OPEN) return;++ const wait = state.lastSentAt + config.minIntervalMs - Date.now();++ if (wait > 0) { state.timer = setTimeout(flush, wait); return; }++ const line = state.queue.shift();++ state.ws.send(JSON.stringify({ type: 'chat', message: line }));++ state.lastSentAt = Date.now();++ if (state.queue.length) state.timer = setTimeout(flush, config.minIntervalMs);++ }++++ /** Queue a line for the configured room. Refused unless signed in and joined. */++ function say(text) {++ if (!state.ready) throw Object.assign(new Error('not joined yet'), { code: 'bot.not_ready' });++ if (state.queue.length >= 5) return false; // never build a backlog to flood with later++ state.queue.push(format(text));++ flush();++ return true;++ }++++ function onChat(msg) {++ if (!inMyRoom(msg)) return;++ const text = String(msg.message || '');++ if (msg.core_username && state.me && msg.core_username.toLowerCase() === state.me.toLowerCase()) return;++ if (text.startsWith(config.prefix.trim())) return; // another bot: never answer bots++ if (!text.startsWith(config.trigger)) return;++ const name = text.slice(config.trigger.length).split(/\s+/)[0].toLowerCase();++ if (!Object.prototype.hasOwnProperty.call(COMMANDS, name)) return;++ say(COMMANDS[name](api, msg));++ }++++ function stop(failure) {++ state.stopped = true;++ state.ready = false;++ state.failure = failure || null;++ clearTimeout(state.timer);++ if (state.ws) { try { state.ws.close(); } catch { /* closed */ } }++ emit('stop', failure || null);++ }++++ function connect() {++ if (state.stopped) return;++ const ws = new WebSocket(config.wsUrl);++ state.ws = ws;++ ws.addEventListener('open', () => ws.send(JSON.stringify(joinFrame)));++ ws.addEventListener('message', (ev) => {++ let msg;++ try { msg = JSON.parse(typeof ev.data === 'string' ? ev.data : Buffer.from(ev.data).toString('utf8')); } catch { return; }++ if (msg.type === 'auth') {++ if (!msg.authenticated) {++ log.error('[bot] the chat token was not accepted; a bot never posts anonymously, stopping');++ return stop({ code: 'bot.not_authenticated' });++ }++ if (!/bot/i.test(`${msg.core_username || ''} ${msg.username || ''}`)) {++ log.error(`[bot] "${msg.core_username}" does not say it is a bot; use a dedicated bot account, stopping`);++ return stop({ code: 'bot.name_required' });++ }++ state.me = msg.core_username || msg.username;++ state.ready = true;++ state.backoffMs = 1000;++ log.log(`[bot] ${state.me} joined ${config.room.kind} ${config.room.id}`);++ emit('ready', { username: state.me });++ return flush();++ }++ if (msg.type === 'chat') return onChat(msg);++ if (msg.type === 'system' && msg.message) log.log(`[bot] system: ${msg.message}`);++ return undefined;++ });++ ws.addEventListener('close', () => {++ state.ready = false;++ if (state.stopped) return;++ const wait = state.backoffMs;++ state.backoffMs = Math.min(30000, state.backoffMs * 2);++ log.log(`[bot] disconnected; reconnecting in ${wait} ms`);++ setTimeout(connect, wait);++ });++ ws.addEventListener('error', () => { /* close follows */ });++ }++++ const api = {++ config,++ start() { state.stopped = false; connect(); return api; },++ stop: () => stop(null),++ say,++ on(name, fn) { listeners[name].push(fn); return api; },++ get ready() { return state.ready; },++ get failure() { return state.failure; },++ get username() { return state.me; },++ _setBackoff(ms) { state.backoffMs = ms; },++ };++ return api;++}++++if (require.main === module) {++ let config;++ try { config = loadConfig(); } catch (err) { console.error(err.message); process.exit(2); }++ const bot = createBot(config).start();++ bot.on('stop', (failure) => { if (failure) process.exitCode = 1; });++ const quit = () => { bot.stop(); setTimeout(() => process.exit(), 100); };++ process.on('SIGINT', quit);++ process.on('SIGTERM', quit);++}++++module.exports = { loadConfig, createBot, COMMANDS };nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "name": "openvibe-example-chat-bot",++ "version": "0.1.0",++ "private": true,++ "description": "Chat bot: a clearly labelled bot that talks in exactly one configured chat room.",++ "license": "MIT",++ "main": "bot.js",++ "scripts": {++ "start": "node --env-file-if-exists=.env bot.js",++ "test": "node test/smoke.test.js"++ },++ "engines": {++ "node": ">=22"++ },++ "dependencies": {},++ "devDependencies": {++ "ws": "^8.18.0"++ }++}nnew file mode 100644@@@ -0,0 +1,67 @@++'use strict';++/**++ * A small local stand-in for OpenVibe.Chat's /ws/chat, for tests only (the SDK's mock platform has++ * no Chat). It plays the documented frames: join (token + channelUserId | streamId) -> auth;++ * chat -> broadcast to the sockets in the same room; system for slow mode. A socket posts only++ * into the room it joined, as on the real server.++ */++const http = require('node:http');++const { WebSocketServer } = require('ws');++++function createMockChatServer({ tokens = {}, streams = {}, slowMs = 0 } = {}) {++ const server = http.createServer((_req, res) => { res.writeHead(404); res.end(); });++ const wss = new WebSocketServer({ server, path: '/ws/chat' });++ const clients = new Map(); // ws -> { user, streamId, channelUserId, lastChatAt }++ const record = { joins: [], posts: [], urls: [] };++++ const roomOf = (c) => `${c.streamId || 0}:${c.channelUserId || 0}`;++ const send = (ws, frame) => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(frame)); };++++ wss.on('connection', (ws, req) => {++ record.urls.push(req.url);++ clients.set(ws, { user: null, streamId: null, channelUserId: null, lastChatAt: 0 });++ ws.on('message', (data) => {++ let msg;++ try { msg = JSON.parse(String(data)); } catch { return; }++ const c = clients.get(ws);++ if (msg.type === 'join') {++ if (msg.token && tokens[msg.token]) c.user = tokens[msg.token];++ c.streamId = parseInt(msg.streamId, 10) || null;++ c.channelUserId = c.streamId ? (streams[c.streamId] || null) : (parseInt(msg.channelUserId, 10) || null);++ record.joins.push({ user: c.user && c.user.username, streamId: c.streamId, channelUserId: c.channelUserId });++ send(ws, { type: 'auth', authenticated: Boolean(c.user), username: c.user ? c.user.username : 'anon1234', core_username: c.user ? c.user.username : null, role: c.user ? 'user' : 'anon', user_id: c.user ? c.user.id : null });++ return;++ }++ if (msg.type === 'chat') {++ const text = String(msg.message || '').trim();++ if (!text) return;++ if (slowMs && Date.now() - c.lastChatAt < slowMs) return send(ws, { type: 'system', message: 'Slow down! You are sending messages too fast.' });++ c.lastChatAt = Date.now();++ const frame = {++ type: 'chat', username: c.user ? c.user.username : 'anon1234', core_username: c.user ? c.user.username : null,++ user_id: c.user ? c.user.id : null, message: text, stream_id: c.streamId, channel_user_id: c.channelUserId,++ is_global: !c.streamId && !c.channelUserId, timestamp: new Date().toISOString(),++ };++ record.posts.push({ room: roomOf(c), username: frame.username, message: text });++ for (const [other, oc] of clients) if (roomOf(oc) === roomOf(c)) send(other, frame);++ }++ return undefined;++ });++ ws.on('close', () => clients.delete(ws));++ });++++ return {++ record,++ async listen() {++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ return `ws://127.0.0.1:${server.address().port}/ws/chat`;++ },++ /** Send a raw frame to every socket (e.g. a frame for another room, to prove the bot ignores it). */++ pushToAll(frame) { for (const ws of clients.keys()) send(ws, frame); },++ /** Drop every connection (the bot must reconnect and rejoin its own room). */++ dropAll() { for (const ws of clients.keys()) ws.terminate(); },++ close: () => new Promise((r) => { for (const ws of clients.keys()) ws.terminate(); wss.close(); server.close(r); }),++ };++}++++module.exports = { createMockChatServer };nnew file mode 100644@@@ -0,0 +1,144 @@++'use strict';++/** Smoke test: the bot against the local /ws/chat mock (the SDK's mock platform has no Chat). */++const assert = require('node:assert/strict');++const { createMockChatServer } = require('./mock-chat-server');++const { createBot, loadConfig } = require('../bot');++++globalThis.fetch = () => { throw new Error('network access in a smoke test'); };++++const BOT_TOKEN = 'hbt_helperbot_test_token_value';++const quiet = () => { const lines = []; return { lines, log: (m) => lines.push(m), error: (m) => lines.push(m) }; };++++async function waitFor(check, what, ms = 3000) {++ const until = Date.now() + ms;++ while (Date.now() < until) {++ if (check()) return;++ await new Promise((r) => setTimeout(r, 10));++ }++ throw new Error(`timed out waiting for ${what}`);++}++++/** A person in a room, over the same protocol. */++async function person(url, token, room) {++ const ws = new WebSocket(url);++ const seen = [];++ ws.addEventListener('message', (ev) => seen.push(JSON.parse(ev.data)));++ await new Promise((r) => ws.addEventListener('open', r));++ ws.send(JSON.stringify({ type: 'join', token, ...room }));++ await waitFor(() => seen.some((m) => m.type === 'auth'), 'the join');++ return { say: (message) => ws.send(JSON.stringify({ type: 'chat', message })), seen, close: () => ws.close() };++}++++(async () => {++ const chat = createMockChatServer({++ tokens: {++ [BOT_TOKEN]: { id: 99, username: 'HelperBot' },++ hbt_other_bot: { id: 98, username: 'OtherBot' },++ hbt_ana: { id: 1, username: 'ana' },++ hbt_bo: { id: 2, username: 'bo' },++ },++ streams: { 555: 7 },++ });++ const url = await chat.listen();++ const env = { OV_CHAT_WS_URL: url, OV_CHAT_TOKEN: BOT_TOKEN, OV_CHAT_CHANNEL_USER_ID: '7', OV_BOT_MIN_INTERVAL_MS: '40', OV_BOT_OWNER: 'example.dev' };++ const out = quiet();++ const bot = createBot(loadConfig(env), { log: out }).start();++ await waitFor(() => bot.ready, 'the bot to join');++ assert.equal(bot.username, 'HelperBot');++++ const ana = await person(url, 'hbt_ana', { channelUserId: 7 });++ const bo = await person(url, 'hbt_bo', { channelUserId: 8 });++ const botPosts = () => chat.record.posts.filter((p) => p.username === 'HelperBot');++++ // 1. A command in its room is answered there, labelled as a bot.++ ana.say('?ping');++ await waitFor(() => botPosts().length === 1, 'the pong');++ assert.deepEqual(botPosts()[0], { room: '0:7', username: 'HelperBot', message: '[bot] pong' });++++ // 2. The same command in another room is not answered, and the bot never posts there.++ bo.say('?ping');++ await new Promise((r) => setTimeout(r, 150));++ assert.equal(botPosts().length, 1);++ assert.ok(chat.record.posts.filter((p) => p.room === '0:8').every((p) => p.username === 'bo'));++++ // 3. A frame about another room delivered to the bot's socket anyway: ignored.++ chat.pushToAll({ type: 'chat', username: 'mallory', core_username: 'mallory', message: '?ping', channel_user_id: 8, stream_id: null, is_global: false });++ chat.pushToAll({ type: 'chat', username: 'mallory', core_username: 'mallory', message: '?ping', channel_user_id: null, stream_id: null, is_global: true });++ await new Promise((r) => setTimeout(r, 150));++ assert.equal(botPosts().length, 1);++++ // 4. It never answers itself or another bot, and ignores unknown commands and ! / commands.++ const other = await person(url, 'hbt_other_bot', { channelUserId: 7 });++ other.say('[bot] ?ping');++ ana.say('?nope');++ ana.say('!ping');++ await new Promise((r) => setTimeout(r, 150));++ assert.equal(botPosts().length, 1);++++ // 5. help / about identify it; replies are paced but not dropped.++ ana.say('?help');++ await new Promise((r) => setTimeout(r, 5));++ ana.say('?about');++ await waitFor(() => botPosts().length === 3, 'help and about');++ assert.equal(botPosts()[1].message, '[bot] commands: ?ping, ?help, ?about');++ assert.equal(botPosts()[2].message, '[bot] I am an automated bot run by example.dev. I only talk in this room and only answer ?commands.');++++ // 6. say() is one line, labelled, bounded; a leading "/" can never become a command.++ bot.say('/ban bo\nnow');++ await waitFor(() => botPosts().length === 4, 'the formatted line');++ assert.equal(botPosts()[3].message, '[bot] /ban bo now');++ bot.say('x'.repeat(500));++ await waitFor(() => botPosts().length === 5, 'the long line');++ assert.equal(botPosts()[4].message.length, 280);++++ // 7. Dropped connection: it reconnects and rejoins ITS room only.++ bot._setBackoff(20);++ chat.dropAll();++ await waitFor(() => !bot.ready, 'the drop');++ await waitFor(() => bot.ready, 'the reconnect');++ const ana2 = await person(url, 'hbt_ana', { channelUserId: 7 });++ ana2.say('?ping');++ await waitFor(() => botPosts().length === 6, 'the pong after reconnect');++ const botJoins = chat.record.joins.filter((j) => j.user === 'HelperBot');++ assert.ok(botJoins.length >= 2);++ assert.ok(botJoins.every((j) => j.channelUserId === 7 && j.streamId === null), 'every join is the configured room');++++ // 8. The token never appears in a URL or a log line.++ assert.ok(chat.record.urls.every((u) => !u.includes(BOT_TOKEN) && !u.includes('token')));++ assert.ok(!out.lines.join('\n').includes(BOT_TOKEN));++ bot.stop();++++ // 9. Stream rooms: configured by stream id, answers only that stream's messages.++ const streamBot = createBot(loadConfig({ ...env, OV_CHAT_CHANNEL_USER_ID: '', OV_CHAT_STREAM_ID: '555' }), { log: quiet() }).start();++ await waitFor(() => streamBot.ready, 'the stream bot');++ const viewer = await person(url, 'hbt_ana', { streamId: 555 });++ const before = botPosts().length;++ viewer.say('?ping');++ await waitFor(() => botPosts().length === before + 1, 'the stream pong');++ assert.equal(botPosts()[before].room, '555:7');++ streamBot.stop();++++ // 10. A token that is not accepted: the bot stops instead of chatting anonymously.++ const anon = createBot(loadConfig({ ...env, OV_CHAT_TOKEN: 'hbt_revoked' }), { log: quiet() });++ const stopped = new Promise((r) => anon.on('stop', r));++ anon.start();++ assert.deepEqual(await stopped, { code: 'bot.not_authenticated' });++++ // 11. An account whose name does not say "bot": refused.++ const person1 = createBot(loadConfig({ ...env, OV_CHAT_TOKEN: 'hbt_ana' }), { log: quiet() });++ const refused = new Promise((r) => person1.on('stop', r));++ person1.start();++ assert.deepEqual(await refused, { code: 'bot.name_required' });++++ // 12. Configuration guards.++ assert.throws(() => loadConfig({ OV_CHAT_TOKEN: 't' }), /exactly one/);++ assert.throws(() => loadConfig({ OV_CHAT_TOKEN: 't', OV_CHAT_CHANNEL_USER_ID: '7', OV_CHAT_STREAM_ID: '5' }), /exactly one/);++ assert.throws(() => loadConfig({ ...env, OV_BOT_PREFIX: '' }), /prefix/i);++ assert.throws(() => loadConfig({ ...env, OV_BOT_PREFIX: '/me ' }), /prefix/i);++ assert.throws(() => loadConfig({ ...env, OV_BOT_TRIGGER: '!' }), /trigger/i);++++ for (const p of [ana, bo, other, ana2, viewer]) p.close();++ await chat.close();++ console.log('chat-bot: ok');++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,11 @@++# realtime: SSE, works without credentials for public events. pull: needs events.event.read.++OV_EVENTS_MODE=realtime++OV_TOPICS=chat.message.created++OV_CURSOR_PATH=./data/cursor.json++OV_POLL_MS=5000++OV_NETWORK_URL=https://openvibe.network++# Optional: an explicit Events origin instead of the one the registry advertises.++OV_EVENTS_URL=++# Only for pull mode (or to read non-public events over realtime).++OV_CLIENT_ID=++OV_CLIENT_SECRET=nnew file mode 100644@@@ -0,0 +1,52 @@++# Event subscriber++++Follow OpenVibe.Events topics without losing or double-handling events, across dropped++connections, restarts and retention gaps.++++```bash++OV_TOPICS='chat.message.created' node --env-file=.env subscriber.js # realtime, no credentials++OV_EVENTS_MODE=pull node --env-file=.env subscriber.js # pull, needs events.event.read++```++++## What it proves++++- **Realtime** (`openvibe-sdk/realtime` `subscribe()`): SSE from `/realtime/stream`. Without++ credentials you receive `public` events only; `internal` events never reach you. After a++ dropped connection or a restart it resumes from the saved cursor with `Last-Event-ID`, and++ nothing is delivered twice.++- **Pull** (`openvibe-sdk/events` `iterate()`): pages from the saved cursor to the head. The++ cursor is saved after each handled event, and moved past non-matching events (the page's++ `next_after_seq`) only once every item of that page is handled. A handler that throws leaves++ the cursor on the last event that succeeded, so the failed event is retried next time.++- **Gaps**: when Events can no longer supply part of the range (retention pruned it, or a restored++ cursor is ahead of the stream), `onResync(gap)` is called. Nothing can replay the missing events,++ so reload whatever state you derive from them from its source of truth.++- The cursor file is replaced atomically (write + rename).++++## Files++++| File | What |++|---|---|++| `subscriber.js` | `createSubscriber()` (`start()`, `stop()`, `pullOnce()`), `createCursorStore()` |++| `test/smoke.test.js` | pull with cursor and failure, pull gap, anonymous realtime, reconnect, restart, cursor-ahead gap |++++## Run the smoke test++++```bash++npm test++```++++The SDK mock never prunes events, so the pull-gap case wraps the mock's `fetch` to add the `gap`++field Events returns after pruning. Realtime gaps come from the mock itself (`cursor_ahead`).++++## Run it against the real platform++++- **Realtime works without an app**: `cp .env.example .env`, keep `OV_EVENTS_MODE=realtime`, set++ `OV_TOPICS`, run `node --env-file=.env subscriber.js`. The stream is++ `https://events.openvibe.network/realtime/stream` (from the registry). You will only see++ events that a producer publishes with visibility `public` on those topics. Chat's++ `chat.message.created` is public, but Chat has not been cut over yet, so whether anything++ arrives depends on which producers are live.++- **Pull cannot be demonstrated yet.** `events.event.read` is an `internal` capability (never++ granted to apps), and the pull API (`/api/v1/events`, checkpoints) is not exposed on Events'++ public host. Checkpoints stored in Events are therefore not used here: the cursor is a local file.nnew file mode 100644@@@ -0,0 +1,18 @@++{++ "name": "openvibe-example-event-subscriber",++ "version": "0.1.0",++ "private": true,++ "description": "Event subscriber: realtime subscription and cursor-based pull with gap handling.",++ "license": "MIT",++ "main": "subscriber.js",++ "scripts": {++ "start": "node --env-file-if-exists=.env subscriber.js",++ "test": "node test/smoke.test.js"++ },++ "engines": {++ "node": ">=22"++ },++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ }++}nnew file mode 100644@@@ -0,0 +1,190 @@++#!/usr/bin/env node++'use strict';++/**++ * Event subscriber: follow OpenVibe.Events topics without losing or double-handling events.++ *++ * node --env-file=.env subscriber.js++ *++ * Two ways in, one cursor discipline:++ *++ * realtime GET /realtime/stream (SSE) through openvibe-sdk/realtime subscribe(). Works without++ * credentials for events whose visibility is `public`. Resumes with Last-Event-ID from++ * the saved cursor after a restart or a dropped connection.++ * pull GET /api/v1/events through openvibe-sdk/events iterate(), from the saved cursor.++ * Needs an app token holding events.event.read.++ *++ * The cursor is saved only AFTER an event was handled, so a crash replays at most the event in++ * hand (make your handler idempotent on event_id). In pull mode the cursor also moves past events++ * that did not match the topic, to the page's next_after_seq, once every item of that page is done.++ *++ * A gap means Events can no longer give you part of the range (retention pruned it, or the cursor++ * is ahead of the stream after a restore). Nothing can replay it, so the subscriber calls++ * onResync(gap): reload whatever state you derive from these events from its source of truth.++ */++const fs = require('node:fs');++const path = require('node:path');++const { createClient, isOpenVibeError } = require('openvibe-sdk/core');++const { createServiceTokenClient } = require('openvibe-sdk/auth');++const { createEventsClient } = require('openvibe-sdk/events');++const { subscribe } = require('openvibe-sdk/realtime');++++function loadConfig(env = process.env) {++ const mode = env.OV_EVENTS_MODE || 'realtime';++ if (!['realtime', 'pull'].includes(mode)) throw Object.assign(new Error('OV_EVENTS_MODE must be realtime or pull'), { code: 'config.invalid' });++ const hasCreds = Boolean(env.OV_CLIENT_ID && env.OV_CLIENT_SECRET);++ if (mode === 'pull' && !hasCreds) {++ throw Object.assign(new Error('pull mode needs OV_CLIENT_ID and OV_CLIENT_SECRET (see .env.example)'), { code: 'config.missing' });++ }++ return {++ mode,++ network: env.OV_NETWORK_URL || 'https://openvibe.network',++ clientId: env.OV_CLIENT_ID || null,++ clientSecret: env.OV_CLIENT_SECRET || null,++ eventsUrl: env.OV_EVENTS_URL || null,++ topics: String(env.OV_TOPICS || 'chat.message.created').split(',').map((t) => t.trim()).filter(Boolean),++ cursorPath: env.OV_CURSOR_PATH || path.join(__dirname, 'data', 'cursor.json'),++ pollMs: Number(env.OV_POLL_MS || 5000),++ };++}++++/** A tiny durable cursor: { realtime: seq, pull: seq } in a JSON file, replaced atomically. */++function createCursorStore(file) {++ let state = {};++ try { state = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch { state = {}; }++ return {++ get: (name) => (Number.isFinite(state[name]) ? state[name] : null),++ set(name, seq) {++ if (!Number.isFinite(seq) || seq === state[name]) return;++ state = { ...state, [name]: seq };++ fs.mkdirSync(path.dirname(file), { recursive: true });++ const tmp = `${file}.${process.pid}.tmp`;++ fs.writeFileSync(tmp, JSON.stringify(state));++ fs.renameSync(tmp, file);++ },++ };++}++++/**++ * createSubscriber(config, { onEvent, onResync, fetch, log })++ * onEvent(event, { seq, via }) handle one event (may be async)++ * onResync(gap, { via }) state derived from events may be stale: reload it++ */++function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = console, cursor = createCursorStore(config.cursorPath) } = {}) {++ if (typeof onEvent !== 'function') throw new TypeError('onEvent is required');++ const tokenProvider = config.clientId && config.clientSecret++ ? createServiceTokenClient({ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch })++ : undefined;++ const client = createClient({++ network: config.network,++ fetch,++ tokenProvider,++ baseUrls: config.eventsUrl ? { events: config.eventsUrl } : undefined,++ });++ const stats = { handled: 0, gaps: 0 };++++ async function gap(g, via) {++ stats.gaps++;++ log.log(`[events] gap via ${via}: ${g.reason || 'pruned'} seq ${g.from_seq}..${g.to_seq}; resyncing`);++ await onResync(g, { via });++ }++++ // ── pull ────────────────────────────────────────────────++ const events = createEventsClient(client);++ /** Read from the saved cursor to the head once. Returns the number of events handled. */++ async function pullOnce({ limit = 100 } = {}) {++ let pageNext = null;++ let handled = 0;++ const iterator = events.iterate({++ topic: config.topics,++ afterSeq: cursor.get('pull') || 0,++ limit,++ onGap: (g) => gap(g, 'pull'),++ // Called when a page arrives, before its items are handed out: the previous page is done.++ onPage: (page) => {++ if (pageNext != null) cursor.set('pull', pageNext);++ pageNext = page.next_after_seq;++ },++ });++ for await (const { seq, event } of iterator) {++ await onEvent(event, { seq, via: 'pull' });++ cursor.set('pull', seq);++ handled++;++ stats.handled++;++ }++ if (pageNext != null) cursor.set('pull', pageNext);++ return handled;++ }++++ let stopped = false;++ let timer = null;++ let sub = null;++++ async function pollLoop() {++ while (!stopped) {++ try { await pullOnce(); } catch (err) {++ log.error(`[events] pull failed: ${isOpenVibeError(err) ? `${err.code} request=${err.requestId}` : err.message}`);++ }++ if (stopped) break;++ await new Promise((r) => { timer = setTimeout(r, config.pollMs); });++ }++ }++++ // ── realtime ────────────────────────────────────────────++ function startRealtime() {++ let chain = Promise.resolve();++ sub = subscribe(config.topics, (event, { seq }) => {++ // Handle strictly in order; save the cursor only after the handler finished.++ chain = chain.then(async () => {++ await onEvent(event, { seq, via: 'realtime' });++ cursor.set('realtime', seq);++ stats.handled++;++ }).catch((err) => log.error(`[events] handler failed at seq ${seq}: ${err.message}`));++ }, {++ client,++ fetch,++ transport: 'fetch',++ lastEventId: cursor.get('realtime'),++ onGap: (g) => { chain = chain.then(() => gap(g, 'realtime')); },++ onOpen: () => log.log(`[events] realtime connected (${config.topics.join(', ')}) from seq ${sub ? sub.lastEventId : cursor.get('realtime')}`),++ onError: (err) => log.error(`[events] realtime: ${isOpenVibeError(err) ? err.code : err.message}`),++ });++ return sub;++ }++++ return {++ client,++ stats,++ cursor,++ pullOnce,++ start() {++ stopped = false;++ if (config.mode === 'pull') { pollLoop(); return null; }++ return startRealtime();++ },++ stop() {++ stopped = true;++ clearTimeout(timer);++ if (sub) sub.close();++ },++ get subscription() { return sub; },++ };++}++++if (require.main === module) {++ let config;++ try { config = loadConfig(); } catch (err) { console.error(err.message); process.exit(2); }++ const s = createSubscriber(config, {++ onEvent(event, { seq, via }) {++ console.log(`${via} #${seq} ${event.event_type} ${event.event_id} subject=${event.subject && `${event.subject.type}:${event.subject.id}`}`);++ },++ onResync(gap) {++ console.log(`resync needed: events ${gap.from_seq}..${gap.to_seq} are gone; reload your state from its source`);++ },++ });++ s.start();++ const stop = () => { s.stop(); process.exit(0); };++ process.on('SIGINT', stop);++ process.on('SIGTERM', stop);++}++++module.exports = { loadConfig, createCursorStore, createSubscriber };nnew file mode 100644@@@ -0,0 +1,141 @@++'use strict';++/**++ * Smoke test: pull with a durable cursor, realtime with resume, and gap handling in both, against++ * the SDK's mock platform (fake Network + Events, including /realtime/stream). No network.++ */++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const { createMockPlatform } = require('openvibe-sdk/testing');++const { createSubscriber, loadConfig, createCursorStore } = require('../subscriber');++++globalThis.fetch = () => { throw new Error('network access in a smoke test'); };++++const APP = 'app_01JEXAMPLESUBSCRIBER000000';++const SECRET = 'ovsec_event_subscriber_test_secret';++const quiet = { log() {}, error() {} };++++async function waitFor(check, what, ms = 3000) {++ const until = Date.now() + ms;++ while (Date.now() < until) {++ if (check()) return;++ await new Promise((r) => setTimeout(r, 10));++ }++ throw new Error(`timed out waiting for ${what}`);++}++++const media = (visibility = 'public', type = 'media.object.uploaded') => ({++ event_type: type, source: 'media', visibility,++ actor: { type: 'service', id: 'media' }, subject: { type: 'object', id: 'med_01JEXAMPLEOBJECT0000000000' },++});++++(async () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-subscriber-'));++ const platform = createMockPlatform({++ clients: { [APP]: { secret: SECRET, grants: [{ capability: 'events.event.read', audience: 'openvibe.events' }] } },++ });++++ // ── pull: durable cursor ────────────────────────────────++ const a = platform.publishEvent(media());++ platform.publishEvent(media('public', 'chat.message.created')); // another topic: skipped, cursor moves past it++ const b = platform.publishEvent(media('internal', 'media.object.deleted'));++ const pullEnv = { OV_EVENTS_MODE: 'pull', OV_CLIENT_ID: APP, OV_CLIENT_SECRET: SECRET, OV_TOPICS: 'media.object.*', OV_CURSOR_PATH: path.join(dir, 'pull.json') };++ let seen = [];++ let s = createSubscriber(loadConfig(pullEnv), { fetch: platform.fetch, log: quiet, onEvent: (e, { seq }) => { seen.push(seq); } });++ assert.equal(await s.pullOnce({ limit: 2 }), 2);++ assert.deepEqual(seen, [a.seq, b.seq]);++ assert.equal(createCursorStore(pullEnv.OV_CURSOR_PATH).get('pull'), 3, 'cursor saved at the head');++++ // A new process resumes from the cursor: only what is new.++ const c = platform.publishEvent(media());++ seen = [];++ s = createSubscriber(loadConfig(pullEnv), { fetch: platform.fetch, log: quiet, onEvent: (e, { seq }) => { seen.push(seq); } });++ assert.equal(await s.pullOnce(), 1);++ assert.deepEqual(seen, [c.seq]);++++ // A handler that fails leaves the cursor on the last event that succeeded.++ const d = platform.publishEvent(media());++ const e = platform.publishEvent(media());++ seen = [];++ s = createSubscriber(loadConfig(pullEnv), {++ fetch: platform.fetch, log: quiet,++ onEvent: (ev, { seq }) => { if (seq === e.seq) throw new Error('boom'); seen.push(seq); },++ });++ await assert.rejects(s.pullOnce(), /boom/);++ assert.equal(createCursorStore(pullEnv.OV_CURSOR_PATH).get('pull'), d.seq);++ s = createSubscriber(loadConfig(pullEnv), { fetch: platform.fetch, log: quiet, onEvent: (ev, { seq }) => { seen.push(seq); } });++ await s.pullOnce();++ assert.deepEqual(seen, [d.seq, e.seq], 'the failed event is retried, nothing is skipped');++++ // A gap (retention pruned the range): onResync is told, reading continues. The mock never++ // prunes, so this wraps its fetch to answer the way Events does after pruning.++ const gapFetch = async (input, init) => {++ const res = await platform.fetch(input, init);++ const url = String(input && input.url ? input.url : input);++ if (!url.includes('/api/v1/events?')) return res;++ const body = await res.json();++ return new Response(JSON.stringify({ ...body, gap: { from_seq: 7, to_seq: 9, reason: 'retention' } }), { status: res.status, headers: { 'Content-Type': 'application/json' } });++ };++ const f = platform.publishEvent(media());++ const resyncs = [];++ seen = [];++ s = createSubscriber(loadConfig(pullEnv), { fetch: gapFetch, log: quiet, onEvent: (ev, { seq }) => { seen.push(seq); }, onResync: (g, { via }) => resyncs.push({ ...g, via }) });++ await s.pullOnce();++ assert.deepEqual(resyncs, [{ from_seq: 7, to_seq: 9, reason: 'retention', via: 'pull' }]);++ assert.deepEqual(seen, [f.seq]);++++ // Pull needs credentials; realtime does not.++ assert.throws(() => loadConfig({ OV_EVENTS_MODE: 'pull' }), /OV_CLIENT_ID/);++++ // ── realtime: anonymous, public events only, resume ────++ const rtEnv = { OV_EVENTS_MODE: 'realtime', OV_TOPICS: 'media.object.*', OV_CURSOR_PATH: path.join(dir, 'realtime.json') };++ const live = [];++ const rt = createSubscriber(loadConfig(rtEnv), { fetch: platform.fetch, log: quiet, onEvent: (ev, { seq }) => { live.push({ seq, type: ev.event_type }); } });++ const subscription = rt.start();++ await waitFor(() => subscription.connected, 'the realtime connection');++ const p1 = platform.publishEvent(media());++ platform.publishEvent(media('internal')); // never reaches an anonymous viewer++ const p2 = platform.publishEvent(media('public', 'media.object.deleted'));++ await waitFor(() => live.length === 2, 'two public events');++ assert.deepEqual(live.map((x) => x.seq), [p1.seq, p2.seq]);++ await waitFor(() => createCursorStore(rtEnv.OV_CURSOR_PATH).get('realtime') === p2.seq, 'the realtime cursor');++++ // The connection drops; an event published meanwhile arrives once after the reconnect.++ platform.dropRealtime();++ const p3 = platform.publishEvent(media());++ await waitFor(() => live.length === 3, 'the event published during the drop');++ assert.equal(live[2].seq, p3.seq);++ await new Promise((r) => setTimeout(r, 100));++ assert.equal(live.length, 3, 'no duplicates after resuming');++ rt.stop();++++ // A restart resumes from the saved cursor: the event published while stopped is delivered.++ const p4 = platform.publishEvent(media());++ const after = [];++ const rt2 = createSubscriber(loadConfig(rtEnv), { fetch: platform.fetch, log: quiet, onEvent: (ev, { seq }) => { after.push(seq); } });++ rt2.start();++ await waitFor(() => after.length === 1, 'the event published while stopped');++ assert.deepEqual(after, [p4.seq]);++ rt2.stop();++++ // A cursor ahead of the stream (restored from another environment): a gap, then live events.++ const aheadPath = path.join(dir, 'ahead.json');++ createCursorStore(aheadPath).set('realtime', 9999);++ const rtGaps = [];++ const rt3 = createSubscriber(loadConfig({ ...rtEnv, OV_CURSOR_PATH: aheadPath }), { fetch: platform.fetch, log: quiet, onEvent() {}, onResync: (g, { via }) => rtGaps.push({ ...g, via }) });++ rt3.start();++ await waitFor(() => rtGaps.length === 1, 'the realtime gap');++ assert.equal(rtGaps[0].reason, 'cursor_ahead');++ assert.equal(rtGaps[0].via, 'realtime');++ rt3.stop();++++ // No credentials were sent on the anonymous stream.++ const streamCalls = platform.stats.requests.filter((r) => r.url.includes('/realtime/stream'));++ assert.ok(streamCalls.length >= 3);++ assert.ok(streamCalls.every((r) => !r.headers.authorization));++++ fs.rmSync(dir, { recursive: true, force: true });++ console.log('event-subscriber: ok');++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,8 @@++# A CONFIDENTIAL developer app holding media.object.upload (audience openvibe.media).++OV_NETWORK_URL=https://openvibe.network++OV_CLIENT_ID=++OV_CLIENT_SECRET=++# Your project id (prj_...): Network puts it in the token's ns claim, and Media stores under it.++OV_MEDIA_NAMESPACE=++# Optional: an explicit Media origin instead of the one the registry advertises.++OV_MEDIA_URL=nnew file mode 100644@@@ -0,0 +1,60 @@++# Media uploader++++Upload a file to OpenVibe.Media as a developer app, using `openvibe-sdk/media` and nothing else.++++```bash++node --env-file=.env upload.js ./logo.png++# {++# "key": "3f2a9c0d1b7e-logo.png",++# "public_url": "https://openvibe.media/f/3f2a9c0d1b7e-logo.png",++# "size": 5120, "mime": "image/png", "sha256": "…", "deduplicated": false++# }++```++++## What it proves++++- An app with only a client id, a secret and one grant (`media.object.upload`) gets a 5-minute++ app token from Network's public token endpoint, asking for exactly that capability (`scope`).++- Media's origin comes from the platform descriptor (`client.discover()`), not a hard-coded host,++ and the contracts version the network runs is checked against the SDK's supported range.++- The upload lands in the app's own namespace. For a developer app that is the **project id**++ (`prj_…`), because Network puts the project id in the token's `ns` claim. Another namespace is++ refused by Media with `403 capability.namespace_denied`.++- Uploads are content-addressed: the same bytes again come back `deduplicated`, so retries are safe.++- The secret is read from the environment and never printed, not even in error messages.++++## Files++++| File | What |++|---|---|++| `upload.js` | `loadConfig()`, `createUploader()`, `explain()` (hints for the usual errors), CLI |++| `test/smoke.test.js` | the flow against `openvibe-sdk/testing` (fake Network + Media) |++++## Run the smoke test++++```bash++npm test # from this folder, or `npm test -- media` from the repository root++```++++No network: Network and Media are the SDK's in-process mock, with real RS256 tokens and the same++audience, capability and namespace checks.++++## Run it against the real platform++++1. Follow the [walkthrough](../../README.md#end-to-end-walkthrough) to create a project and a++ **confidential** app, and request `media.object.upload` on it.++2. `cp .env.example .env` and fill in `OV_CLIENT_ID`, `OV_CLIENT_SECRET` and++ `OV_MEDIA_NAMESPACE` (your `prj_…` project id).++3. `node --env-file=.env upload.js ./some-file.png`++++What has to be true on the platform side first, and is **not** true by default today:++++- **Sandbox apps get no Media token.** Network issues sandbox tokens only for audiences listed in++ `DEV_SANDBOX_AUDIENCES`, which is empty by default, so a sandbox app gets `invalid_target`.++ Media must also accept `env: sandbox` tokens. Until both opt in, use a production app (staff++ switch the project to `sandbox+production`).++- **The capability must be in your project's allowance** (staff set it; the default allowance is empty).++- **Media needs a tenant named after your project id.** Media only accepts uploads for tenants it++ knows, and tenants are created by the Media operators.++- Media v1 accepts app tokens for **upload only**. Listing, reading metadata and deleting++ (`media.files.list/get/delete`) need a Media app API key, which developer apps do not have.nnew file mode 100644@@@ -0,0 +1,18 @@++{++ "name": "openvibe-example-media-uploader",++ "version": "0.1.0",++ "private": true,++ "description": "Media uploader: upload a file to OpenVibe.Media with an app token.",++ "license": "MIT",++ "main": "upload.js",++ "scripts": {++ "start": "node --env-file-if-exists=.env upload.js",++ "test": "node test/smoke.test.js"++ },++ "engines": {++ "node": ">=22"++ },++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ }++}nnew file mode 100644@@@ -0,0 +1,74 @@++'use strict';++/**++ * Smoke test: the uploader against openvibe-sdk/testing's mock platform (fake Network + Media at++ * their public origins, real RS256 tokens, audience/capability/namespace checks). No network.++ */++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const { createMockPlatform } = require('openvibe-sdk/testing');++const { createUploader, explain, loadConfig } = require('../upload');++++// CI has no network: anything that is not the mock platform fails loudly.++globalThis.fetch = () => { throw new Error('network access in a smoke test'); };++++const APP = 'app_01JEXAMPLEMEDIA0000000000';++const PRJ = 'prj_01JEXAMPLEPROJECT00000000';++const OTHER = 'prj_01JSOMEONEELSE00000000000';++const SECRET = 'ovsec_media_uploader_test_secret';++++(async () => {++ const platform = createMockPlatform({++ clients: {++ [APP]: { secret: SECRET, grants: [{ capability: 'media.object.upload', audience: 'openvibe.media', namespaces: [PRJ] }] },++ app_01JNOGRANTS000000000000000: { secret: 'x', grants: [] },++ },++ mediaApps: { [PRJ]: {}, [OTHER]: {} },++ });++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-'));++ const file = path.join(dir, 'hello.txt');++ fs.writeFileSync(file, 'hello from an OpenVibe example\n');++++ const env = { OV_CLIENT_ID: APP, OV_CLIENT_SECRET: SECRET, OV_MEDIA_NAMESPACE: PRJ };++++ // 1. Upload: token for openvibe.media, Media origin from discovery, file stored in the project namespace.++ const uploader = createUploader(loadConfig(env), { fetch: platform.fetch });++ const out = await uploader.upload(file);++ assert.match(out.key, /^[0-9a-f]{12}-hello\.txt$/);++ assert.equal(out.public_url, `https://openvibe.media/f/${out.key}`);++ assert.equal(out.mime, 'text/plain');++ assert.equal(out.sha256, require('node:crypto').createHash('sha256').update(fs.readFileSync(file)).digest('hex'));++ assert.ok(platform.state.files.has(`${PRJ}|${out.key}`), 'stored under the project namespace');++++ // The upload carried a short-lived Network token (not an API key) and a trace.++ const call = platform.stats.requests.find((r) => r.url === `https://openvibe.media/api/v1/${PRJ}/files`);++ assert.ok(call, 'Media was called at the discovered origin');++ assert.match(call.headers.authorization, /^Bearer [\w-]+\.[\w-]+\.[\w-]+$/);++ assert.match(call.headers.traceparent, /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);++ const token = platform.stats.requests.find((r) => r.url.endsWith('/oauth/token'));++ assert.ok(token);++++ // 2. Same bytes again: content-addressed, deduplicated, safe to retry.++ const again = await uploader.upload(file);++ assert.equal(again.key, out.key);++ assert.equal(again.deduplicated, true);++++ // 3. Another project's namespace is refused by Media (the token's ns is this project only).++ const wrongNs = createUploader(loadConfig({ ...env, OV_MEDIA_NAMESPACE: OTHER }), { fetch: platform.fetch });++ await assert.rejects(wrongNs.upload(file), (err) => err.status === 403 && err.code === 'capability.namespace_denied');++++ // 4. No grant: Network issues no token for the audience.++ const noGrant = createUploader(loadConfig({ ...env, OV_CLIENT_ID: 'app_01JNOGRANTS000000000000000', OV_CLIENT_SECRET: 'x' }), { fetch: platform.fetch });++ await assert.rejects(noGrant.upload(file), (err) => err.code === 'invalid_scope' && /request it/i.test(explain(err)));++++ // 5. Wrong secret: invalid_client, and the secret never appears in the explanation.++ const badSecret = createUploader(loadConfig({ ...env, OV_CLIENT_SECRET: 'wrong-secret-value' }), { fetch: platform.fetch });++ await assert.rejects(badSecret.upload(file), (err) => err.code === 'invalid_client' && !explain(err).includes('wrong-secret-value'));++++ // 6. Missing configuration names the variables, never their values.++ assert.throws(() => loadConfig({ OV_CLIENT_SECRET: SECRET }), (err) => err.code === 'config.missing' && /OV_CLIENT_ID/.test(err.message) && !err.message.includes(SECRET));++++ fs.rmSync(dir, { recursive: true, force: true });++ console.log('media-uploader: ok');++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,108 @@++#!/usr/bin/env node++'use strict';++/**++ * Media uploader: upload a file to OpenVibe.Media as a developer app.++ *++ * node --env-file=.env upload.js ./logo.png++ *++ * 1. Ask OpenVibe.Network for an app token (client credentials, audience openvibe.media, scope++ * media.object.upload).++ * 2. Find Media's origin in the platform descriptor (registry discovery), never a hard-coded host.++ * 3. Upload to the Media namespace your grant names. For a developer app that is your project id++ * (prj_…), because Network puts the project id in the token's `ns` claim.++ *++ * Only openvibe-sdk and the public token endpoint are used. The client secret is read from the++ * environment and never printed.++ */++const fs = require('node:fs');++const path = require('node:path');++const { createClient, isOpenVibeError } = require('openvibe-sdk/core');++const { createServiceTokenClient } = require('openvibe-sdk/auth');++const { createMediaClient } = require('openvibe-sdk/media');++++const TYPES = {++ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',++ '.svg': 'image/svg+xml', '.txt': 'text/plain', '.json': 'application/json', '.pdf': 'application/pdf',++ '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.webm': 'video/webm',++};++++function loadConfig(env = process.env) {++ const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET', 'OV_MEDIA_NAMESPACE'].filter((k) => !env[k]);++ if (missing.length) {++ const err = new Error(`missing environment variables: ${missing.join(', ')} (see .env.example)`);++ err.code = 'config.missing';++ throw err;++ }++ return {++ network: env.OV_NETWORK_URL || 'https://openvibe.network',++ clientId: env.OV_CLIENT_ID,++ clientSecret: env.OV_CLIENT_SECRET,++ namespace: env.OV_MEDIA_NAMESPACE,++ mediaUrl: env.OV_MEDIA_URL || null,++ };++}++++function createUploader(config, { fetch } = {}) {++ const tokens = createServiceTokenClient({++ network: config.network,++ clientId: config.clientId,++ clientSecret: config.clientSecret,++ scope: { 'openvibe.media': 'media.object.upload' }, // ask for exactly what this program uses++ fetch,++ });++ const client = createClient({++ network: config.network,++ fetch,++ tokenProvider: tokens,++ baseUrls: config.mediaUrl ? { media: config.mediaUrl } : undefined,++ });++++ return {++ client,++ /** Upload one file -> { key, url, public_url, size, mime, sha256, deduplicated? } */++ async upload(filePath) {++ const discovery = await client.discover(); // origins + contracts version check++ const media = createMediaClient(client, { app: config.namespace, publicOrigin: await client.origin('media') });++ const bytes = await fs.promises.readFile(filePath);++ const filename = path.basename(filePath);++ const contentType = TYPES[path.extname(filename).toLowerCase()] || 'application/octet-stream';++ const file = await media.upload(bytes, { filename, contentType });++ return { ...file, contracts: discovery.contractsVersion };++ },++ };++}++++/** A human hint for the errors a new developer is most likely to meet. */++function explain(err) {++ const code = isOpenVibeError(err) ? err.code : err && err.code;++ switch (code) {++ case 'config.missing': return err.message;++ case 'invalid_client': return 'Network refused the client id or secret. Check OV_CLIENT_ID / OV_CLIENT_SECRET (rotate the secret in your project if it was lost).';++ case 'invalid_target': return 'Network does not issue tokens for openvibe.media to this app. Sandbox apps only get tokens for audiences that accept sandbox traffic; see the README.';++ case 'invalid_scope': return 'The app holds no approved media.object.upload grant. Request it on your app and have a project admin approve it.';++ case 'capability.namespace_denied': return 'Your token is not valid for this namespace. OV_MEDIA_NAMESPACE must be your project id (prj_...).';++ case 'token.sandbox_refused': return 'Media does not accept sandbox tokens yet. See the README section "Running against the real platform".';++ default: return isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''}${err.requestId ? ` (request ${err.requestId})` : ''}` : String(err && err.message);++ }++}++++async function main(argv = process.argv.slice(2)) {++ const file = argv[0];++ if (!file) {++ console.error('usage: node upload.js <file>');++ return 2;++ }++ try {++ const uploader = createUploader(loadConfig());++ const out = await uploader.upload(file);++ console.log(JSON.stringify({ key: out.key, public_url: out.public_url, size: out.size, mime: out.mime, sha256: out.sha256, deduplicated: Boolean(out.deduplicated) }, null, 2));++ return 0;++ } catch (err) {++ console.error(`upload failed: ${explain(err)}`);++ return 1;++ }++}++++if (require.main === module) main().then((code) => { process.exitCode = code; });++++module.exports = { loadConfig, createUploader, explain, main };nnew file mode 100644@@@ -0,0 +1,2 @@++# Only for "node validate.js mod.json --registry" (the public registry needs no credentials).++OV_NETWORK_URL=https://openvibe.networknnew file mode 100644@@@ -0,0 +1,51 @@++# Mod manifest++++Write a `mods.mod-manifest@1` document and check it before you publish, with openvibe-contracts.++++```bash++node validate.js mod.json # schema + catalog + compatibility, offline++node validate.js mod.json --registry # also asks the network's public registry about each capability++node validate.js --new-id # a fresh mod_<ULID> for a draft++```++++`mod.json` is a small valid manifest: a `games.browser` mod on the `games-content@1` runtime that++asks for `games.world.announce` and `games.prop.place`.++++## What it proves++++- **Schema**: `contracts.validate('mods.mod-manifest@1', manifest)` (ADR-013), with JSON-pointer paths.++- **Catalog**: every requested capability exists in the openvibe-contracts catalog, is `public`++ (`first-party` and `internal` capabilities are never granted to mods or third-party apps, so++ asking for one can only fail at install time) and `active` (a `planned` one is a warning).++- **Publisher** is a user or an app.++- **Compatibility**: the runtime's major version is inside `compatibility.runtime`; a++ `compatibility.contracts` range that excludes the contracts release you validated with is a warning.++- **Registry** (optional): each capability as the live network's registry knows it, with++ `openvibe-sdk/registry` and no credentials.++++A manifest only **requests** capabilities. What a mod may do is the approved subset of its++install's grants; trust tiers are metadata and never change a grant check.++++## Files++++| File | What |++|---|---|++| `mod.json` | the example manifest |++| `validate.js` | `validateManifest()`, `checkRegistry()`, CLI |++| `test/smoke.test.js` | the example validates; each class of mistake is caught at its path |++++## Run the smoke test++++```bash++npm test++```++++## Run it against the real platform++++Validation is offline and works today: `node validate.js mod.json`. The `--registry` check reads++`https://openvibe.network/api/v1/registry/capabilities/<id>` (public, no credentials).++++Publishing is not public yet: registering a mod and managing installs (`games.mod.manage`) is a++`first-party` capability, install grants (`mods.grant.manage`) are `planned`, and the manifest++editor and release flow belong to OpenVibe.Codes, which has not launched. Mod ids are assigned++by that flow; `--new-id` only gives a draft a well-formed id.nnew file mode 100644@@@ -0,0 +1,15 @@++{++ "id": "mod_01M366EZ0WM23BN1VW7J3WVWRR",++ "name": "Welcome Sign",++ "version": "1.0.0",++ "description": "Places a welcome sign in the town square and greets players when a world opens.",++ "publisher": { "type": "app", "id": "app_01M366EZ7F3S8VA3MZ3Y7ENCTJ" },++ "target": "games.browser",++ "runtime": "games-content@1",++ "permissions": {++ "capabilities": ["games.world.announce", "games.prop.place"]++ },++ "resources": { "cpuMs": 2, "memoryMb": 16, "storageMb": 1 },++ "compatibility": { "runtime": ">=1.0.0 <2.0.0", "contracts": ">=0.26.0 <1.0.0" },++ "homepage": "https://github.com/OpenVibers/OpenVibe.Examples/tree/main/examples/mod-manifest"++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "name": "openvibe-example-mod-manifest",++ "version": "0.1.0",++ "private": true,++ "description": "Mod manifest: validate a mods.mod-manifest@1 document with openvibe-contracts.",++ "license": "MIT",++ "main": "validate.js",++ "scripts": {++ "start": "node --env-file-if-exists=.env validate.js",++ "test": "node test/smoke.test.js"++ },++ "engines": {++ "node": ">=22"++ },++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0"++ }++}nnew file mode 100644@@@ -0,0 +1,80 @@++'use strict';++/** Smoke test: the example manifest validates; each class of mistake is caught with its path. */++const assert = require('node:assert/strict');++const { createMockPlatform } = require('openvibe-sdk/testing');++const { validateManifest, checkRegistry, main } = require('../validate');++const example = require('../mod.json');++++globalThis.fetch = () => { throw new Error('network access in a smoke test'); };++++const clone = () => JSON.parse(JSON.stringify(example));++const paths = (r) => r.errors.map((e) => e.path);++++(async () => {++ // The shipped example is valid, with no warnings.++ const ok = validateManifest(example);++ assert.deepEqual(ok, { valid: true, errors: [], warnings: [] });++++ // Schema errors (openvibe-contracts): missing permissions, a two-segment capability id, a bad id.++ let m = clone(); delete m.permissions;++ assert.equal(validateManifest(m).valid, false);++ assert.ok(validateManifest(m).errors.some((e) => /permissions/.test(e.message)));++ m = clone(); m.permissions.capabilities = ['games.announce'];++ assert.deepEqual(paths(validateManifest(m)), ['/permissions/capabilities/0']);++ m = clone(); m.id = 'mod_not-a-ulid';++ assert.deepEqual(paths(validateManifest(m)), ['/id']);++++ // Catalog errors: unknown, internal and first-party capabilities are never grantable to a mod.++ m = clone(); m.permissions.capabilities = ['games.world.announce', 'games.world.teleport', 'events.event.publish', 'games.mod.manage'];++ const cat = validateManifest(m);++ assert.deepEqual(paths(cat), ['/permissions/capabilities/1', '/permissions/capabilities/2', '/permissions/capabilities/3']);++ assert.match(cat.errors[0].message, /not in the openvibe-contracts/);++ assert.match(cat.errors[1].message, /is internal:/);++ assert.match(cat.errors[2].message, /first-party/);++++ // A planned (not yet active) public capability is a warning, not an error.++ m = clone(); m.permissions.capabilities = ['network.project.manage'];++ const planned = validateManifest(m);++ assert.equal(planned.valid, true);++ assert.match(planned.warnings[0].message, /planned/);++++ // Publisher must be a user or an app.++ m = clone(); m.publisher = { type: 'service', id: 'live' };++ assert.deepEqual(paths(validateManifest(m)), ['/publisher/type']);++++ // Compatibility: the runtime major must be inside compatibility.runtime.++ m = clone(); m.runtime = 'games-content@2';++ assert.deepEqual(paths(validateManifest(m)), ['/compatibility/runtime']);++ m = clone(); m.compatibility.runtime = 'whenever';++ assert.deepEqual(paths(validateManifest(m)), ['/compatibility/runtime']);++ m = clone(); m.compatibility.contracts = '>=1.0.0';++ const future = validateManifest(m);++ assert.equal(future.valid, true);++ assert.match(future.warnings[0].message, /outside >=1\.0\.0/);++++ // Outbound hosts are allowed but flagged for review.++ m = clone(); m.resources.outboundHosts = ['api.example.com'];++ assert.match(validateManifest(m).warnings[0].message, /reviewed/);++++ // The optional registry check uses the public registry (no token); here, the SDK's mock.++ const platform = createMockPlatform({ capabilities: [{ id: 'games.world.announce', owner: 'games', visibility: 'public', status: 'active' }] });++ const reg = await checkRegistry(example, { fetch: platform.fetch });++ assert.deepEqual(reg, [++ { id: 'games.world.announce', registered: true, visibility: 'public', status: 'active' },++ { id: 'games.prop.place', registered: false, visibility: null, status: null },++ ]);++ assert.ok(platform.stats.requests.every((r) => !r.headers.authorization), 'the registry needs no credentials');++++ // CLI exit codes.++ const out = console.log;++ const err = console.error;++ console.log = () => {};++ console.error = () => {};++ try {++ assert.equal(await main([require.resolve('../mod.json')]), 0);++ assert.equal(await main([]), 2);++ assert.equal(await main(['/nonexistent/mod.json']), 2);++ } finally { console.log = out; console.error = err; }++++ console.log('mod-manifest: ok');++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,117 @@++#!/usr/bin/env node++'use strict';++/**++ * Mod manifest: check a mod manifest before you publish it.++ *++ * node validate.js mod.json # schema + catalog checks, offline++ * node validate.js mod.json --registry # also ask the network's registry about each capability++ * node validate.js --new-id # a fresh mod_<ULID> for a draft manifest++ *++ * 1. Schema: openvibe-contracts validate('mods.mod-manifest@1', manifest) (ADR-013).++ * 2. Catalog: every requested capability must exist in the contracts catalog, be `active`, and have++ * visibility `public`. first-party and internal capabilities are never granted to mods or apps,++ * so asking for one can only fail at install time; this says so now.++ * 3. Compatibility: the `runtime` major must fall inside compatibility.runtime, and++ * compatibility.contracts (if given) should include the contracts release you validated with.++ * 4. With --registry: openvibe-sdk/registry capability(id) on the live network (public, no token).++ *++ * The manifest only REQUESTS capabilities. What a mod may actually do is the approved subset of an++ * install's grants; trust tiers are metadata and never change a grant check.++ */++const fs = require('node:fs');++const contracts = require('openvibe-contracts');++const { createClient, satisfiesRange } = require('openvibe-sdk/core');++const { createRegistryClient } = require('openvibe-sdk/registry');++++const CONTRACTS_VERSION = require('openvibe-contracts/package.json').version;++const PUBLISHER_TYPES = new Set(['user', 'app']);++++/** -> { valid, errors: [{ path, message }], warnings: [{ path, message }] } */++function validateManifest(manifest, { catalog = contracts.capabilities, contractsVersion = CONTRACTS_VERSION } = {}) {++ const errors = [];++ const warnings = [];++ const schema = contracts.validate('mods.mod-manifest@1', manifest);++ if (!schema.valid) {++ for (const e of schema.errors) errors.push({ path: e.path || '/', message: e.message });++ return { valid: false, errors, warnings };++ }++++ if (!PUBLISHER_TYPES.has(manifest.publisher.type)) {++ errors.push({ path: '/publisher/type', message: `a mod is published by a user or an app, not a ${manifest.publisher.type}` });++ }++++ manifest.permissions.capabilities.forEach((id, i) => {++ const p = `/permissions/capabilities/${i}`;++ const cap = catalog.get(id);++ if (!cap) return errors.push({ path: p, message: `${id} is not in the openvibe-contracts ${contractsVersion} catalog` });++ if (cap.visibility !== 'public') return errors.push({ path: p, message: `${id} is ${cap.visibility}: it is never granted to mods or third-party apps` });++ if (cap.status !== 'active') warnings.push({ path: p, message: `${id} is ${cap.status}, not active yet` });++ return undefined;++ });++ if (!manifest.permissions.capabilities.length) warnings.push({ path: '/permissions/capabilities', message: 'the mod asks for no capabilities' });++++ const major = Number(manifest.runtime.split('@')[1]);++ try {++ if (!satisfiesRange(`${major}.0.0`, manifest.compatibility.runtime)) {++ errors.push({ path: '/compatibility/runtime', message: `runtime ${manifest.runtime} is outside ${manifest.compatibility.runtime}` });++ }++ } catch {++ errors.push({ path: '/compatibility/runtime', message: `not a semver range: ${manifest.compatibility.runtime}` });++ }++ if (manifest.compatibility.contracts) {++ try {++ if (!satisfiesRange(contractsVersion, manifest.compatibility.contracts)) {++ warnings.push({ path: '/compatibility/contracts', message: `validated with openvibe-contracts ${contractsVersion}, outside ${manifest.compatibility.contracts}` });++ }++ } catch {++ errors.push({ path: '/compatibility/contracts', message: `not a semver range: ${manifest.compatibility.contracts}` });++ }++ }++ if ((manifest.resources.outboundHosts || []).length) {++ warnings.push({ path: '/resources/outboundHosts', message: 'outbound network access is reviewed before a mod is installed' });++ }++ return { valid: errors.length === 0, errors, warnings };++}++++/** Ask the network registry whether each requested capability is registered there. */++async function checkRegistry(manifest, { network = process.env.OV_NETWORK_URL || 'https://openvibe.network', fetch } = {}) {++ const registry = createRegistryClient(createClient({ network, fetch }));++ const out = [];++ for (const id of manifest.permissions.capabilities) {++ const cap = await registry.capability(id);++ out.push({ id, registered: Boolean(cap), visibility: cap ? cap.visibility || null : null, status: cap ? cap.status || null : null });++ }++ return out;++}++++async function main(argv = process.argv.slice(2)) {++ if (argv.includes('--new-id')) {++ console.log(contracts.ids.newId('mod'));++ return 0;++ }++ const file = argv.find((a) => !a.startsWith('--'));++ if (!file) { console.error('usage: node validate.js <manifest.json> [--registry] | --new-id'); return 2; }++ let manifest;++ try { manifest = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (err) {++ console.error(`cannot read ${file}: ${err.message}`);++ return 2;++ }++ const result = validateManifest(manifest);++ for (const e of result.errors) console.log(`error ${e.path}: ${e.message}`);++ for (const w of result.warnings) console.log(`warning ${w.path}: ${w.message}`);++ if (result.valid && argv.includes('--registry')) {++ try {++ for (const c of await checkRegistry(manifest)) {++ console.log(`registry ${c.id}: ${c.registered ? `${c.visibility}, ${c.status}` : 'not registered on this network'}`);++ }++ } catch (err) {++ console.log(`registry check failed: ${err.code || err.message}`);++ }++ }++ console.log(result.valid ? `${file}: valid mods.mod-manifest@1 (openvibe-contracts ${CONTRACTS_VERSION})` : `${file}: invalid`);++ return result.valid ? 0 : 1;++}++++if (require.main === module) main().then((code) => { process.exitCode = code; });++++module.exports = { validateManifest, checkRegistry, main, CONTRACTS_VERSION };nnew file mode 100644@@@ -0,0 +1,7 @@++# A CONFIDENTIAL developer app in your OpenVibe project (client id app_..., secret ovsec_... shown once).++OV_NETWORK_URL=https://openvibe.network++OV_CLIENT_ID=++OV_CLIENT_SECRET=++# Audiences to ask for app tokens (comma-separated); /status shows the capabilities each token carries.++OV_AUDIENCES=openvibe.media,openvibe.tools++OV_PORT=3002nnew file mode 100644@@@ -0,0 +1,51 @@++# Node server app++++A small HTTP server that runs as a developer app: it authenticates with **client credentials**++and finds the platform through the **registry**.++++```bash++node --env-file=.env server.js++curl -s localhost:3002/status++```++++`GET /status` answers with:++++- `contracts`: the openvibe-contracts release the network runs, and whether this SDK supports it;++- `services`: every registered service with its status and origin (from the registry);++- `grants`: for each audience in `OV_AUDIENCES`, the capabilities an app token for it carries++ (approved grants within your project's allowance), each described by the registry, plus the++ token's `project_id`, `env` and `ns`; or why Network refused (`invalid_scope`, `invalid_target`).++++## What it proves++++- Discovery with no credentials: `client.discover()` reads `https://openvibe.network/.well-known/openvibe`++ and `openvibe-sdk/registry` reads `/api/v1/registry/*`. Both are public.++- One cached app token per audience (`createServiceTokenClient` as the client's `tokenProvider`),++ refreshed shortly before its 5-minute expiry. A second `/status` does not ask Network again.++- The incoming request's `traceparent` continues on every outbound call (`client.fromRequest(req)`).++- Errors are reported by stable code and request id; the secret and tokens never leave the process.++++## Files++++| File | What |++|---|---|++| `server.js` | `loadConfig()`, `createApp()` (the HTTP server and `status()`), `peekClaims()` |++| `test/smoke.test.js` | discovery, registry, grants per audience, token caching, trace propagation |++++## Run the smoke test++++```bash++npm test++```++++## Run it against the real platform++++1. Create a project and a **confidential** app ([walkthrough](../../README.md#end-to-end-walkthrough)).++2. `cp .env.example .env`, fill in `OV_CLIENT_ID` and `OV_CLIENT_SECRET`, set `OV_AUDIENCES` to++ the audiences you hold grants for (`openvibe.media`, `openvibe.tools`, …).++3. `node --env-file=.env server.js` and open `http://localhost:3002/status`.++++Discovery and the registry work for anyone today. The `grants` part shows a token only when++Network will issue one: a **sandbox** app gets `invalid_target` for every audience until staff list++that audience in `DEV_SANDBOX_AUDIENCES` (empty by default), and any app gets `invalid_scope` until++a grant is approved inside the project's allowance (also empty by default).nnew file mode 100644@@@ -0,0 +1,18 @@++{++ "name": "openvibe-example-node-server-app",++ "version": "0.1.0",++ "private": true,++ "description": "Node server app: client-credentials app token and registry discovery.",++ "license": "MIT",++ "main": "server.js",++ "scripts": {++ "start": "node --env-file-if-exists=.env server.js",++ "test": "node test/smoke.test.js"++ },++ "engines": {++ "node": ">=22"++ },++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ }++}nnew file mode 100644@@@ -0,0 +1,122 @@++#!/usr/bin/env node++'use strict';++/**++ * Node server app: an app principal that authenticates with client credentials and finds the++ * platform through the registry.++ *++ * node --env-file=.env server.js # http://localhost:3002/status++ *++ * GET /status answers:++ * - the platform descriptor (Network's /.well-known/openvibe): service origins and the contracts++ * release the network runs, checked against the range this SDK supports;++ * - the registry's services;++ * - for each audience in OV_AUDIENCES, the capabilities an app token for that audience carries++ * (approved grants within the project's allowance), described by the registry.++ *++ * The app token is requested from the public token endpoint and cached per audience until shortly++ * before it expires (5-minute lifetime). The client secret is never printed or returned.++ */++const http = require('node:http');++const { createClient, isOpenVibeError } = require('openvibe-sdk/core');++const { createServiceTokenClient } = require('openvibe-sdk/auth');++const { createRegistryClient } = require('openvibe-sdk/registry');++++function loadConfig(env = process.env) {++ const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET'].filter((k) => !env[k]);++ if (missing.length) {++ const err = new Error(`missing environment variables: ${missing.join(', ')} (see .env.example)`);++ err.code = 'config.missing';++ throw err;++ }++ return {++ network: env.OV_NETWORK_URL || 'https://openvibe.network',++ clientId: env.OV_CLIENT_ID,++ clientSecret: env.OV_CLIENT_SECRET,++ audiences: String(env.OV_AUDIENCES || 'openvibe.media').split(',').map((s) => s.trim()).filter(Boolean),++ port: Number(env.OV_PORT || 3002),++ };++}++++/**++ * The claims of a token this process just received from Network over TLS. Only read for display;++ * the services that receive the token are the ones that verify it.++ */++function peekClaims(token) {++ try { return JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8')); } catch { return {}; }++}++++function createApp(config, { fetch, log = console } = {}) {++ const tokens = createServiceTokenClient({ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch });++ const client = createClient({ network: config.network, fetch, tokenProvider: tokens });++++ /** What an app token for `audience` carries right now, or the reason there is none. */++ async function grantsFor(audience, registry) {++ let token;++ try {++ token = await tokens.getToken({ audience });++ } catch (err) {++ if (isOpenVibeError(err) && ['invalid_scope', 'invalid_target', 'unauthorized_client'].includes(err.code)) {++ return { audience, capabilities: [], refused: err.code, detail: err.detail || null };++ }++ throw err;++ }++ const claims = peekClaims(token);++ const capabilities = await Promise.all((claims.cap || []).map(async (id) => {++ const c = await registry.capability(id);++ return { id, known: Boolean(c), visibility: c ? c.visibility || null : null, description: c ? c.description || null : null };++ }));++ return {++ audience,++ capabilities,++ subject: claims.sub || null,++ project_id: claims.project_id || null,++ env: claims.env || null,++ namespaces: claims.ns || [],++ expires_at: claims.exp ? new Date(claims.exp * 1000).toISOString() : null,++ };++ }++++ async function status(req) {++ // Continue the caller's trace on every outbound call (traceparent + X-OpenVibe-Request-Id).++ const scoped = req ? client.fromRequest(req) : client;++ const registry = createRegistryClient(scoped);++ const d = await scoped.discover();++ const services = await registry.services();++ return {++ app: config.clientId,++ network: config.network,++ contracts: { version: d.contractsVersion, supported_range: d.contractsRange, compatible: d.compatible },++ services: services.map((s) => ({ id: s.id, status: s.status, origin: d.origins[s.id] || s.publicOrigin || null })),++ grants: await Promise.all(config.audiences.map((a) => grantsFor(a, registry))),++ checked_at: new Date().toISOString(),++ };++ }++++ const server = http.createServer(async (req, res) => {++ const send = (code, body) => {++ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });++ res.end(JSON.stringify(body, null, 2));++ };++ const url = new URL(req.url, 'http://app.invalid');++ try {++ if (req.method === 'GET' && url.pathname === '/healthz') return send(200, { ok: true });++ if (req.method === 'GET' && url.pathname === '/status') return send(200, await status(req));++ return send(404, { error: 'not_found' });++ } catch (err) {++ // Log the stable code and request id, never tokens or secrets.++ log.error(`[status] ${isOpenVibeError(err) ? `${err.code} request=${err.requestId}` : err.message}`);++ return send(502, { error: isOpenVibeError(err) ? err.code : 'upstream_error', request_id: isOpenVibeError(err) ? err.requestId : null });++ }++ });++++ return { server, client, tokens, status };++}++++if (require.main === module) {++ let config;++ try { config = loadConfig(); } catch (err) { console.error(err.message); process.exit(2); }++ const { server } = createApp(config);++ server.listen(config.port, () => console.log(`node-server-app: http://localhost:${config.port}/status (app ${config.clientId})`));++}++++module.exports = { loadConfig, createApp, peekClaims };nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++/** Smoke test: client-credentials app token + registry discovery against the SDK's mock platform. */++const assert = require('node:assert/strict');++const http = require('node:http');++const { createMockPlatform } = require('openvibe-sdk/testing');++const { createApp, loadConfig } = require('../server');++++const realFetch = globalThis.fetch;++globalThis.fetch = () => { throw new Error('network access in a smoke test'); };++++const APP = 'app_01JEXAMPLESERVER000000000';++const PRJ = 'prj_01JEXAMPLEPROJECT00000000';++const SECRET = 'ovsec_node_server_test_secret';++++function get(port, path, headers = {}) {++ return new Promise((resolve, reject) => {++ http.get({ host: '127.0.0.1', port, path, headers }, (res) => {++ let body = '';++ res.on('data', (c) => { body += c; });++ res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(body) }));++ }).on('error', reject);++ });++}++++(async () => {++ const platform = createMockPlatform({++ clients: { [APP]: { secret: SECRET, grants: [{ capability: 'media.object.upload', audience: 'openvibe.media', namespaces: [PRJ] }] } },++ capabilities: [{ id: 'media.object.upload', owner: 'media', visibility: 'public', status: 'active', description: 'Upload an object into a granted namespace' }],++ contractsVersion: '0.26.0',++ });++ const logs = [];++ const log = { error: (m) => logs.push(m), log: (m) => logs.push(m) };++ const app = createApp(loadConfig({ OV_CLIENT_ID: APP, OV_CLIENT_SECRET: SECRET, OV_AUDIENCES: 'openvibe.media,openvibe.tools' }), { fetch: platform.fetch, log });++ await new Promise((r) => app.server.listen(0, '127.0.0.1', r));++ const { port } = app.server.address();++++ const traceparent = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01';++ const res = await get(port, '/status', { traceparent });++ assert.equal(res.status, 200);++ const s = res.body;++++ // Discovery: origins and the contracts version check.++ assert.equal(s.contracts.version, '0.26.0');++ assert.equal(s.contracts.compatible, true);++ const media = s.services.find((x) => x.id === 'media');++ assert.equal(media.origin, 'https://openvibe.media');++++ // The token for openvibe.media carries the approved grant, described by the registry.++ const mediaGrants = s.grants.find((g) => g.audience === 'openvibe.media');++ assert.deepEqual(mediaGrants.capabilities.map((c) => c.id), ['media.object.upload']);++ assert.equal(mediaGrants.capabilities[0].visibility, 'public');++ assert.deepEqual(mediaGrants.namespaces, [PRJ]);++++ // No grant for openvibe.tools: reported, not thrown.++ const toolsGrants = s.grants.find((g) => g.audience === 'openvibe.tools');++ assert.equal(toolsGrants.refused, 'invalid_scope');++ assert.deepEqual(toolsGrants.capabilities, []);++++ // The incoming trace continues on the outbound registry calls.++ const registryCall = platform.stats.requests.find((r) => r.url.includes('/api/v1/registry/services'));++ assert.ok(registryCall.headers.traceparent.startsWith('00-0af7651916cd43dd8448eb211c80319c-'));++++ // Tokens are cached per audience: a second /status does not ask Network for a new media token.++ const before = platform.stats.tokenRequests;++ await get(port, '/status');++ assert.equal(platform.stats.tokenRequests - before, 1, 'only the refused audience is asked again');++++ // Nothing secret leaves the process.++ assert.ok(!JSON.stringify(s).includes(SECRET));++ assert.ok(!logs.join('\n').includes(SECRET));++++ // Health.++ assert.deepEqual((await get(port, '/healthz')).body, { ok: true });++++ app.server.close();++ globalThis.fetch = realFetch;++ console.log('node-server-app: ok');++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,10 @@++# A CONFIDENTIAL developer app with the redirect URI below registered exactly as written.++OV_NETWORK_URL=https://openvibe.network++OV_CLIENT_ID=++OV_CLIENT_SECRET=++# Sign-in asks for capabilities of ONE audience; the app needs an approved grant for each.++OV_AUDIENCE=openvibe.media++OV_SCOPE=media.object.upload media.object.read++OV_REDIRECT_URI=http://localhost:3009/callback++# Defaults to the redirect URI's port.++OV_PORT=3009nnew file mode 100644@@@ -0,0 +1,62 @@++# OAuth app++++"Sign in with OpenVibe" for a **confidential** developer app: authorization code + PKCE, with the++code exchanged on the server.++++```bash++node --env-file=.env server.js # open http://localhost:3009 and click "Sign in with OpenVibe"++```++++## What it proves++++| Step | Where | What |++|---|---|---|++| `/login` | server | `startAuthorization()` makes the PKCE pair (S256) and `state`; both stay in this browser's **server-side** session (HttpOnly, SameSite=Lax cookie); redirect to Network's `/oauth/authorize` with the capability ids to ask for (`scope`) |++| Network | | the person signs in and chooses to continue; Network redirects back with `code` and `state` |++| `/callback` | server | `readCallback()` checks `state` (refuses callbacks this browser did not start, and `error=` answers); the code is exchanged **once**, with the client secret, the PKCE verifier and the `audience`; the token is verified offline against Network's JWKS (signature, issuer, audience, expiry); the session id is replaced (no session fixation) |++| `/api/me` | server | who signed in (`usr_…`), and the capabilities this app may use for them |++++What comes back is an **app token acting for the person**: `actor_type: app`, `sub: app:<your app id>`,++`on_behalf_of: usr_…`, `cap` = what they authorized, `aud` = the one audience, 5-minute lifetime++and **no refresh token**. When it expires the person signs in again. The app checks the token was++minted for itself (`sub`) before trusting `on_behalf_of`.++++Also covered by the test: a replayed callback, a forged `state`, a declined consent++(`oauth.access_denied`), a code bound to another PKCE challenge (`invalid_grant`), sign-out, and++that neither the secret nor any token reaches the browser or the logs.++++## Files++++| File | What |++|---|---|++| `server.js` | `createApp()`, `exchangeAppCode()`, `signedInSubject()` |++| `test/smoke.test.js` | the flow against `openvibe-sdk/testing` (`platform.authorize()` stands in for the consent page) |++++## Run the smoke test++++```bash++npm test++```++++## Run it against the real platform++++1. Create a project and a **confidential** app with the redirect URI++ `http://localhost:3009/callback` ([walkthrough](../../README.md#end-to-end-walkthrough)).++ `localhost` redirect URIs are allowed for sandbox apps only; a production app needs an https one.++2. Request a grant for every capability in `OV_SCOPE` (for example `media.object.read` for audience++ `openvibe.media`). Signing in needs at least one approved grant for `OV_AUDIENCE`.++3. `cp .env.example .env`, fill in `OV_CLIENT_ID`, `OV_CLIENT_SECRET`, `OV_AUDIENCE`, `OV_SCOPE`.++4. `node --env-file=.env server.js` and sign in **as a member of the project** (a sandbox app can++ only be authorized by its project's members).++++What blocks it today: Network mints the token only if the app may have one for `OV_AUDIENCE`. A++sandbox app gets `invalid_target` at the exchange until staff list that audience in++`DEV_SANDBOX_AUDIENCES` (empty by default). A production app avoids that but needs an https++redirect URI, so it has to run somewhere with TLS. Network's account chooser does not list the++requested capabilities yet (no consent screen).++++## Why not `exchangeCode()`?++++`openvibe-sdk` 0.2.2's `exchangeCode()` requires a client secret and cannot send `audience`, which++Network requires when the client is a developer app. `exchangeAppCode()` here calls the same++public token endpoint through the SDK's core client (with retries off: a code works once).nnew file mode 100644@@@ -0,0 +1,18 @@++{++ "name": "openvibe-example-oauth-app",++ "version": "0.1.0",++ "private": true,++ "description": "OAuth app: server-side authorization code + PKCE exchange with OpenVibe.Network.",++ "license": "MIT",++ "main": "server.js",++ "scripts": {++ "start": "node --env-file-if-exists=.env server.js",++ "test": "node test/smoke.test.js"++ },++ "engines": {++ "node": ">=22"++ },++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ }++}nnew file mode 100644@@@ -0,0 +1,207 @@++#!/usr/bin/env node++'use strict';++/**++ * OAuth app: "Sign in with OpenVibe" for a confidential developer app, done on the server.++ *++ * node --env-file=.env server.js # http://localhost:3009++ *++ * /login make a PKCE pair and a state (openvibe-sdk/auth), keep them in this browser's++ * server-side session, redirect to Network's /oauth/authorize.++ * /callback check the state (readCallback), exchange the code on the server with the client++ * secret AND the PKCE verifier, verify the token offline against Network's JWKS, then++ * start a fresh session (new session id: no session fixation).++ * /api/me who signed in, and what this app may do on their behalf.++ *++ * What comes back is an APP token acting for the person (actor_type app, on_behalf_of usr_…,++ * cap = the capabilities they authorized, 5-minute lifetime, no refresh token). When it expires++ * the person signs in again. The secret, the verifier and tokens never reach the browser or logs.++ */++const crypto = require('node:crypto');++const http = require('node:http');++const { createClient, isOpenVibeError } = require('openvibe-sdk/core');++const { startAuthorization, readCallback, verifyUserToken } = require('openvibe-sdk/auth');++++const SESSION_COOKIE = 'ov_example_sid';++const PENDING_TTL_MS = 10 * 60 * 1000;++++function loadConfig(env = process.env) {++ const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET', 'OV_AUDIENCE', 'OV_SCOPE'].filter((k) => !env[k]);++ if (missing.length) {++ throw Object.assign(new Error(`missing environment variables: ${missing.join(', ')} (see .env.example)`), { code: 'config.missing' });++ }++ const redirectUri = env.OV_REDIRECT_URI || 'http://localhost:3009/callback';++ return {++ network: env.OV_NETWORK_URL || 'https://openvibe.network',++ clientId: env.OV_CLIENT_ID,++ clientSecret: env.OV_CLIENT_SECRET,++ redirectUri,++ audience: env.OV_AUDIENCE,++ scope: env.OV_SCOPE.split(/[\s,]+/).filter(Boolean),++ port: Number(env.OV_PORT || new URL(redirectUri).port || 3009),++ secureCookie: redirectUri.startsWith('https://'),++ };++}++++/**++ * POST <network>/oauth/token (authorization_code) for a developer app. openvibe-sdk 0.2.2's++ * exchangeCode() cannot send `audience`, which Network requires for app clients, so this calls++ * the public token endpoint through the SDK's core client. No retries: a code works once.++ */++async function exchangeAppCode(client, { clientId, clientSecret, code, codeVerifier, redirectUri, audience, scope }) {++ return client.json({++ service: 'network', path: '/oauth/token', method: 'POST', auth: false, retries: 0, idempotencyKey: false,++ urlencoded: {++ grant_type: 'authorization_code', code, redirect_uri: redirectUri, code_verifier: codeVerifier,++ client_id: clientId, client_secret: clientSecret || undefined, audience, scope: scope && scope.length ? scope.join(' ') : undefined,++ },++ });++}++++/** Who signed in, from verified claims: an app token names them in on_behalf_of. */++function signedInSubject(claims, clientId) {++ if (claims.actor_type === 'app') {++ if (claims.sub !== `app:${clientId}`) return null; // a token minted for some other app++ return claims.on_behalf_of || null;++ }++ return claims.subject_id || null; // a plain user token++}++++const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);++++function parseCookies(header) {++ const out = {};++ for (const part of String(header || '').split(';')) {++ const i = part.indexOf('=');++ if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());++ }++ return out;++}++++function createApp(config, { fetch, log = console } = {}) {++ const client = createClient({ network: config.network, fetch });++ const sessions = new Map(); // sid -> { pending?, auth? } (use a real store in production)++++ const newSid = () => crypto.randomBytes(32).toString('base64url');++ const cookie = (sid, maxAge) => `${SESSION_COOKIE}=${sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${config.secureCookie ? '; Secure' : ''}`;++ function sessionOf(req) {++ const sid = parseCookies(req.headers.cookie)[SESSION_COOKIE];++ return sid && sessions.has(sid) ? { sid, s: sessions.get(sid) } : null;++ }++ function current(req) {++ const found = sessionOf(req);++ if (!found || !found.s.auth) return null;++ if (found.s.auth.expiresAt <= Date.now()) { found.s.auth = null; return null; }++ return found.s.auth;++ }++++ /** Forget abandoned sign-ins and expired sessions (an in-memory store has to clean up after itself). */++ function prune() {++ const now = Date.now();++ for (const [sid, s] of sessions) {++ const pendingDead = !s.pending || now - s.pending.at > PENDING_TTL_MS;++ const authDead = !s.auth || s.auth.expiresAt <= now;++ if (pendingDead && authDead) sessions.delete(sid);++ }++ }++++ async function login(req, res) {++ prune();++ const found = sessionOf(req);++ const sid = found ? found.sid : newSid();++ const { url, state, codeVerifier } = await startAuthorization({++ network: config.network, clientId: config.clientId, redirectUri: config.redirectUri, scope: config.scope,++ });++ sessions.set(sid, { ...(found ? found.s : {}), pending: { state, codeVerifier, at: Date.now() } });++ res.writeHead(302, { Location: url, 'Set-Cookie': cookie(sid, 3600), 'Cache-Control': 'no-store' });++ res.end();++ }++++ async function callback(req, res) {++ const found = sessionOf(req);++ const pending = found && found.s.pending;++ if (found) found.s.pending = null; // one attempt per /login++ if (!pending || Date.now() - pending.at > PENDING_TTL_MS) return page(res, 400, 'Sign-in expired', 'Start again from the sign-in link.');++ let code;++ try {++ ({ code } = readCallback(`http://app.invalid${req.url}`, { expectedState: pending.state }));++ } catch (err) {++ log.log(`[oauth] callback refused: ${err.code}`);++ return page(res, 400, 'Sign-in failed', err.code === 'oauth.state_mismatch' ? 'This sign-in was not started here.' : `Network answered ${err.code}.`, err.code);++ }++ try {++ const tokens = await exchangeAppCode(client, { ...config, code, codeVerifier: pending.codeVerifier });++ const d = await client.discover();++ const claims = await verifyUserToken(tokens.access_token, {++ jwks: d.jwksUri || `${config.network}/api/.well-known/jwks`, issuer: d.issuer || undefined, audience: config.audience,++ allowServiceTokens: true, fetch,++ });++ const subject = signedInSubject(claims, config.clientId);++ if (!subject) return page(res, 400, 'Sign-in failed', 'The token does not name a person for this app.', 'token.no_subject');++ sessions.delete(found.sid); // fresh session id after sign-in++ const sid = newSid();++ sessions.set(sid, {++ auth: {++ subject, app: claims.sub || null, projectId: claims.project_id || null, env: claims.env || null,++ capabilities: claims.cap || String(tokens.scope || '').split(' ').filter(Boolean),++ expiresAt: claims.exp * 1000, accessToken: tokens.access_token,++ },++ });++ log.log(`[oauth] signed in ${subject}`);++ res.writeHead(302, { Location: '/', 'Set-Cookie': cookie(sid, Math.max(60, claims.exp - Math.floor(Date.now() / 1000))), 'Cache-Control': 'no-store' });++ return res.end();++ } catch (err) {++ log.error(`[oauth] exchange failed: ${isOpenVibeError(err) ? `${err.code} request=${err.requestId}` : err.message}`);++ return page(res, 400, 'Sign-in failed', 'Network did not accept the sign-in. Start again.', isOpenVibeError(err) ? err.code : 'exchange_failed');++ }++ }++++ function page(res, status, title, text, code) {++ res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });++ res.end(`<!doctype html><meta charset="utf-8"><title>${escapeHtml(title)}</title><h1>${escapeHtml(title)}</h1><p>${escapeHtml(text)}</p>${code ? `<p><code data-error="${escapeHtml(code)}">${escapeHtml(code)}</code></p>` : ''}<p><a href="/">Back</a></p>`);++ }++++ const server = http.createServer(async (req, res) => {++ const url = new URL(req.url, 'http://app.invalid');++ try {++ if (req.method === 'GET' && url.pathname === '/') {++ const auth = current(req);++ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });++ return res.end(`<!doctype html><meta charset="utf-8"><title>OpenVibe OAuth app example</title><h1>OpenVibe OAuth app example</h1>${auth++ ? `<p>Signed in with OpenVibe as <code>${escapeHtml(auth.subject)}</code>.</p><p>This app may: ${auth.capabilities.map((c) => `<code>${escapeHtml(c)}</code>`).join(', ') || 'nothing'}.</p><form method="post" action="/logout"><button>Sign out</button></form>`++ : '<p><a href="/login">Sign in with OpenVibe</a></p>'}`);++ }++ if (req.method === 'GET' && url.pathname === '/login') return await login(req, res);++ if (req.method === 'GET' && url.pathname === new URL(config.redirectUri).pathname) return await callback(req, res);++ if (req.method === 'GET' && url.pathname === '/api/me') {++ const auth = current(req);++ res.writeHead(auth ? 200 : 401, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });++ return res.end(JSON.stringify(auth++ ? { subject: auth.subject, app: auth.app, project_id: auth.projectId, env: auth.env, capabilities: auth.capabilities, expires_at: new Date(auth.expiresAt).toISOString() }++ : { error: 'not_signed_in' }));++ }++ if (req.method === 'POST' && url.pathname === '/logout') {++ const found = sessionOf(req);++ if (found) sessions.delete(found.sid);++ res.writeHead(303, { Location: '/', 'Set-Cookie': cookie('', 0) });++ return res.end();++ }++ res.writeHead(404, { 'Content-Type': 'text/plain' });++ return res.end('not found');++ } catch (err) {++ log.error(`[oauth] ${err.message}`);++ if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('error'); }++ return undefined;++ }++ });++++ return { server, sessions };++}++++if (require.main === module) {++ let config;++ try { config = loadConfig(); } catch (err) { console.error(err.message); process.exit(2); }++ const { server } = createApp(config);++ server.listen(config.port, () => console.log(`oauth-app: http://localhost:${config.port} (redirect URI ${config.redirectUri})`));++}++++module.exports = { loadConfig, createApp, exchangeAppCode, signedInSubject };nnew file mode 100644@@@ -0,0 +1,113 @@++'use strict';++/**++ * Smoke test: authorization code + PKCE with a server-side exchange, against the SDK's mock++ * platform. The mock has no /oauth/authorize page; platform.authorize() stands in for the person++ * choosing "Continue" on Network, and it verifies PKCE at the token endpoint like Network does.++ */++const assert = require('node:assert/strict');++const { createMockPlatform } = require('openvibe-sdk/testing');++const { createApp, loadConfig, signedInSubject } = require('../server');++++const realFetch = globalThis.fetch;++globalThis.fetch = () => { throw new Error('network access in a smoke test'); };++++const APP = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TR';++const SECRET = 'ovsec_oauth_app_test_secret_value';++++(async () => {++ const platform = createMockPlatform({ users: [{ username: 'ana' }] });++ const ana = [...platform.state.users.values()][0];++ const logs = [];++ const log = { log: (m) => logs.push(m), error: (m) => logs.push(m) };++ const env = { OV_CLIENT_ID: APP, OV_CLIENT_SECRET: SECRET, OV_AUDIENCE: 'openvibe.media', OV_SCOPE: 'media.object.upload media.object.read' };++ const app = createApp(loadConfig(env), { fetch: platform.fetch, log });++ await new Promise((r) => app.server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${app.server.address().port}`;++ const config = loadConfig(env);++ platform.addClient(APP, { secret: SECRET, redirectUris: [config.redirectUri] });++++ const get = (path, cookie, method = 'GET') => realFetch(`${base}${path}`, { method, redirect: 'manual', headers: cookie ? { cookie } : {} });++ const cookieOf = (res) => (res.headers.get('set-cookie') || '').split(';')[0];++++ /** /login -> the authorize URL's parameters and this browser's session cookie. */++ async function startLogin() {++ const res = await get('/login');++ assert.equal(res.status, 302);++ const to = new URL(res.headers.get('location'));++ return { to, cookie: cookieOf(res) };++ }++ const callbackPath = (params) => `/callback?${new URLSearchParams(params)}`;++++ // 1. Sign in.++ const { to, cookie } = await startLogin();++ assert.equal(`${to.origin}${to.pathname}`, 'https://openvibe.network/oauth/authorize');++ assert.equal(to.searchParams.get('client_id'), APP);++ assert.equal(to.searchParams.get('redirect_uri'), config.redirectUri);++ assert.equal(to.searchParams.get('code_challenge_method'), 'S256');++ assert.match(to.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);++ assert.equal(to.searchParams.get('scope'), 'media.object.upload media.object.read');++ assert.match(cookie, /^ov_example_sid=/);++ const res1 = await get('/login');++ assert.match(res1.headers.get('set-cookie'), /HttpOnly; SameSite=Lax/);++ assert.ok(!to.toString().includes(SECRET), 'the secret never goes to the browser');++++ const code = platform.authorize({ clientId: APP, redirectUri: config.redirectUri, subjectId: ana.subject_id, codeChallenge: to.searchParams.get('code_challenge'), scope: to.searchParams.get('scope') });++ const cb = await get(callbackPath({ code, state: to.searchParams.get('state') }), cookie);++ assert.equal(cb.status, 302);++ assert.equal(cb.headers.get('location'), '/');++ const signedIn = cookieOf(cb);++ assert.notEqual(signedIn, cookie, 'a new session id after sign-in');++++ const me = await get('/api/me', signedIn);++ assert.equal(me.status, 200);++ const body = await me.json();++ assert.equal(body.subject, ana.subject_id);++ assert.ok(!JSON.stringify(body).includes('access_token'));++ assert.match(await (await get('/', signedIn)).text(), new RegExp(`Signed in with OpenVibe as <code>${ana.subject_id}</code>`));++++ // The exchange sent the verifier, the secret and the audience to the token endpoint.++ const exchange = platform.stats.requests.filter((r) => r.url.endsWith('/oauth/token'));++ assert.equal(exchange.length, 1);++ assert.equal((await get('/api/me', cookie)).status, 401, 'the pre-login session id is dead');++++ // 2. The same callback again (replayed link): refused, no second exchange.++ const replay = await get(callbackPath({ code, state: to.searchParams.get('state') }), cookie);++ assert.equal(replay.status, 400);++++ // 3. A callback this browser did not start (state mismatch, login CSRF): refused before any exchange.++ const second = await startLogin();++ const mismatch = await get(callbackPath({ code: 'whatever', state: 'not-the-state' }), second.cookie);++ assert.equal(mismatch.status, 400);++ assert.match(await mismatch.text(), /oauth\.state_mismatch/);++++ // 4. The person declined on Network.++ const third = await startLogin();++ const denied = await get(callbackPath({ error: 'access_denied', state: third.to.searchParams.get('state') }), third.cookie);++ assert.equal(denied.status, 400);++ assert.match(await denied.text(), /oauth\.access_denied/);++++ // 5. A code bound to a different PKCE challenge (stolen code): the token endpoint refuses it.++ const fourth = await startLogin();++ const other = await require('openvibe-sdk/auth').createPkcePair();++ const stolen = platform.authorize({ clientId: APP, redirectUri: config.redirectUri, subjectId: ana.subject_id, codeChallenge: other.codeChallenge });++ const pkce = await get(callbackPath({ code: stolen, state: fourth.to.searchParams.get('state') }), fourth.cookie);++ assert.equal(pkce.status, 400);++ assert.match(await pkce.text(), /invalid_grant/);++++ // 6. Sign out.++ const out = await get('/logout', signedIn, 'POST');++ assert.equal(out.status, 303);++ assert.equal((await get('/api/me', signedIn)).status, 401);++++ // App tokens name the person in on_behalf_of and must be for this app.++ assert.equal(signedInSubject({ actor_type: 'app', sub: `app:${APP}`, on_behalf_of: 'usr_X' }, APP), 'usr_X');++ assert.equal(signedInSubject({ actor_type: 'app', sub: 'app:app_SOMEONEELSE', on_behalf_of: 'usr_X' }, APP), null);++++ // Nothing secret in the logs.++ const text = logs.join('\n');++ assert.ok(!text.includes(SECRET));++ assert.ok(!/eyJ[\w-]+\.[\w-]+\./.test(text), 'no tokens in logs');++++ app.server.close();++ console.log('oauth-app: ok');++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,10 @@++# A CONFIDENTIAL developer app holding tools.job.create and tools.job.read (audience openvibe.tools).++OV_NETWORK_URL=https://openvibe.network++OV_CLIENT_ID=++OV_CLIENT_SECRET=++# The Tools satellite that runs the job type (img.process runs on img.openvibe.tools).++OV_TOOLS_JOBS_URL=https://img.openvibe.tools++OV_JOB_TYPE=img.process++OV_JOB_INPUT={"tool":"convert","format":"webp"}++OV_OUT_DIR=./out++OV_JOB_STATE=./.job-state.jsonnnew file mode 100644@@@ -0,0 +1,59 @@++# Tool job++++Run an OpenVibe.Tools job (here: convert an image to WebP) as a developer app, and survive++dropped connections and restarts without running the job twice.++++```bash++node --env-file=.env run-job.js ./photo.png++# submitted job_01K… (queued)++# job.queued++# job.running++# job.progress 50% converting++# job.succeeded 100%++# saved ./out/photo.webp++```++++## What it proves++++- An app token for `openvibe.tools` scoped to `tools.job.create tools.job.read` (both `public`++ capabilities a developer app can hold).++- **Submit once.** `POST /api/v1/jobs` carries an `Idempotency-Key` derived from the request++ (type, input, file). If the process dies after submitting, running it again gets the **same**++ job back (`200 Idempotent-Replayed: true`), not a second one.++- **Reattach.** The job id and the last event id are kept in `.job-state.json`; a restart++ reattaches to the job instead of submitting.++- **Resume the stream.** `GET /api/v1/jobs/:id/events` (SSE) is reopened with `Last-Event-ID`++ after a drop, and Tools replays only later events: every event is seen once, in order.++ `204` means the job finished and nothing is newer.++- The result files are downloaded with the same token. Jobs are owner-scoped: another app gets `404`.++++## Files++++| File | What |++|---|---|++| `run-job.js` | `createJobRunner()` (`submit`, `watch`, `get`, `download`, `run`), `readEventStream()`, CLI |++| `test/mock-tools-server.js` | a local `/api/v1/jobs` that behaves as documented (the SDK mock has no Tools) |++| `test/smoke.test.js` | full run with a dropped stream, reattach after a crash, idempotent resubmit, 204, owner scoping, no grant |++++## Run the smoke test++++```bash++npm test++```++++Network is the SDK's mock platform; the Tools mock verifies its tokens (RS256, audience++`openvibe.tools`, the `tools.job.*` capability) and plays queued → running → progress → succeeded.++++## Run it against the real platform++++1. Create a project and a **confidential** app, request `tools.job.create` and `tools.job.read`++ ([walkthrough](../../README.md#end-to-end-walkthrough)).++2. `cp .env.example .env`, fill in `OV_CLIENT_ID` and `OV_CLIENT_SECRET`.++3. `node --env-file=.env run-job.js ./photo.png`++++`img.process` runs on `https://img.openvibe.tools`; audio and docs jobs run on their own++satellites (set `OV_TOOLS_JOBS_URL`, `OV_JOB_TYPE`, `OV_JOB_INPUT`). Tools accepts app tokens on++this API today. What has to be true on the platform side first: the capabilities are in your++project's allowance, and your app can get an `openvibe.tools` token — a **sandbox** app cannot++until staff list `openvibe.tools` in Network's `DEV_SANDBOX_AUDIENCES` (and Tools accepts++sandbox tokens), so use a production app.nnew file mode 100644@@@ -0,0 +1,18 @@++{++ "name": "openvibe-example-tool-job",++ "version": "0.1.0",++ "private": true,++ "description": "Tool job: submit an OpenVibe.Tools job and reattach to its progress stream.",++ "license": "MIT",++ "main": "run-job.js",++ "scripts": {++ "start": "node --env-file-if-exists=.env run-job.js",++ "test": "node test/smoke.test.js"++ },++ "engines": {++ "node": ">=22"++ },++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ }++}nnew file mode 100644@@@ -0,0 +1,228 @@++#!/usr/bin/env node++'use strict';++/**++ * Tool job: run an OpenVibe.Tools job as a developer app and survive disconnects and restarts.++ *++ * node --env-file=.env run-job.js ./photo.png # img.process, convert to WebP by default++ *++ * 1. App token for openvibe.tools (client credentials, scope tools.job.create tools.job.read).++ * 2. POST /api/v1/jobs (multipart: type, input, file) with an Idempotency-Key derived from the++ * request, so re-running after a crash gets the SAME job back (200 Idempotent-Replayed).++ * 3. The job id and the last event id are saved in a state file. Running the script again++ * reattaches to the job instead of submitting a new one.++ * 4. GET /api/v1/jobs/:id/events (SSE). When the stream drops, reconnect with Last-Event-ID: the++ * server replays only later events. 204 means the job finished and nothing is newer.++ * 5. GET /api/v1/jobs/:id for the result, then download each result file.++ *++ * openvibe-sdk has no jobs client yet (sdk-jobs), so the job routes are called with the SDK's core++ * client, which still supplies the token, deadlines, safe retries, problem errors and tracing. The++ * event stream is the exception: the core client cannot return a streaming body, so it is read++ * with fetch and the same app token.++ */++const crypto = require('node:crypto');++const fs = require('node:fs');++const path = require('node:path');++const { createClient, isOpenVibeError, OpenVibeError, startSpan } = require('openvibe-sdk/core');++const { createServiceTokenClient } = require('openvibe-sdk/auth');++++const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);++++function loadConfig(env = process.env) {++ const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET'].filter((k) => !env[k]);++ if (missing.length) {++ throw Object.assign(new Error(`missing environment variables: ${missing.join(', ')} (see .env.example)`), { code: 'config.missing' });++ }++ let input;++ try { input = JSON.parse(env.OV_JOB_INPUT || '{"tool":"convert","format":"webp"}'); } catch {++ throw Object.assign(new Error('OV_JOB_INPUT must be a JSON object'), { code: 'config.invalid' });++ }++ return {++ network: env.OV_NETWORK_URL || 'https://openvibe.network',++ clientId: env.OV_CLIENT_ID,++ clientSecret: env.OV_CLIENT_SECRET,++ toolsUrl: env.OV_TOOLS_JOBS_URL || 'https://img.openvibe.tools',++ type: env.OV_JOB_TYPE || 'img.process',++ input,++ outDir: env.OV_OUT_DIR || path.join(process.cwd(), 'out'),++ statePath: env.OV_JOB_STATE || path.join(process.cwd(), '.job-state.json'),++ };++}++++/** Parse a text/event-stream body: calls onEvent({ id, event, data }) per event, onRetry(ms). */++async function readEventStream(body, onEvent, onRetry = () => {}) {++ const reader = body.getReader();++ const decoder = new TextDecoder();++ let buf = '';++ let cur = { data: [] };++ const line = (l) => {++ if (l === '') {++ if (cur.data.length) onEvent({ id: cur.id, event: cur.event || 'message', data: cur.data.join('\n') });++ cur = { data: [] };++ return;++ }++ if (l.startsWith(':')) return;++ const i = l.indexOf(':');++ const field = i < 0 ? l : l.slice(0, i);++ const value = i < 0 ? '' : l.slice(i + 1).replace(/^ /, '');++ if (field === 'data') cur.data.push(value);++ else if (field === 'event') cur.event = value;++ else if (field === 'id') cur.id = value;++ else if (field === 'retry' && /^\d+$/.test(value)) onRetry(Number(value));++ };++ for (;;) {++ const { value, done } = await reader.read();++ if (done) break;++ buf += decoder.decode(value, { stream: true });++ let m;++ while ((m = /\r\n|\n|\r/.exec(buf))) {++ if (m[0] === '\r' && m.index === buf.length - 1) break;++ line(buf.slice(0, m.index));++ buf = buf.slice(m.index + m[0].length);++ }++ }++}++++function createJobRunner(config, { fetch, log = console } = {}) {++ const tokens = createServiceTokenClient({++ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch,++ scope: { 'openvibe.tools': 'tools.job.create tools.job.read' },++ });++ const client = createClient({ network: config.network, fetch, tokenProvider: tokens, baseUrls: { tools: config.toolsUrl } });++ const fetchImpl = fetch || globalThis.fetch;++++ const loadState = () => { try { return JSON.parse(fs.readFileSync(config.statePath, 'utf8')); } catch { return null; } };++ const saveState = (s) => {++ const tmp = `${config.statePath}.tmp`;++ fs.writeFileSync(tmp, JSON.stringify(s, null, 2));++ fs.renameSync(tmp, config.statePath);++ };++ const clearState = () => { try { fs.unlinkSync(config.statePath); } catch { /* none */ } };++++ /** Submit, or reattach to the job this state file already names for the same request. */++ async function submit(filePath) {++ const bytes = await fs.promises.readFile(filePath);++ const fingerprint = crypto.createHash('sha256').update(config.type).update('\0').update(JSON.stringify(config.input)).update('\0').update(bytes).digest('hex');++ const saved = loadState();++ if (saved && saved.fingerprint === fingerprint && saved.jobId) {++ log.log(`reattaching to ${saved.jobId}`);++ return { jobId: saved.jobId, reattached: true };++ }++ const form = new FormData();++ form.append('type', config.type);++ form.append('input', JSON.stringify(config.input));++ form.append('file', new Blob([bytes]), path.basename(filePath));++ const res = await client.request({++ service: 'tools', method: 'POST', path: '/api/v1/jobs', form,++ idempotencyKey: `ex-${fingerprint.slice(0, 40)}`, // same request -> same job, even after a crash++ });++ const job = res.data;++ saveState({ jobId: job.id, fingerprint, lastEventId: 0, submittedAt: new Date().toISOString() });++ log.log(`${res.headers.get('idempotent-replayed') ? 'existing' : 'submitted'} ${job.id} (${job.state})`);++ return { jobId: job.id, reattached: false, replayed: Boolean(res.headers.get('idempotent-replayed')) };++ }++++ /**++ * Open the job's event stream. The SDK's core client cannot hand back a streaming body++ * (responseType 'response' drops the Response), so this one call uses fetch directly with the++ * same app token.++ */++ async function openStream(jobId, lastEventId) {++ const url = `${config.toolsUrl.replace(/\/+$/, '')}/api/v1/jobs/${encodeURIComponent(jobId)}/events`;++ for (let attempt = 0; attempt < 2; attempt++) {++ const token = await tokens.getToken({ audience: 'openvibe.tools' });++ const res = await fetchImpl(url, {++ headers: { Accept: 'text/event-stream', Authorization: `Bearer ${token}`, 'Last-Event-ID': String(lastEventId), traceparent: startSpan().traceparent },++ });++ if (res.status === 401 && attempt === 0) { tokens.invalidate({ audience: 'openvibe.tools' }); continue; }++ if (!res.ok) {++ const body = await res.json().catch(() => null);++ throw OpenVibeError.fromResponse({ status: res.status, body, method: 'GET', url });++ }++ return res;++ }++ throw new Error('unreachable');++ }++++ /** Follow the job's events until it finishes, reconnecting with Last-Event-ID. */++ async function watch(jobId, { onEvent = () => {}, maxReconnects = 20 } = {}) {++ let retryMs = 1000;++ let reconnects = 0;++ for (;;) {++ const state = loadState() || {};++ const lastEventId = state.jobId === jobId ? state.lastEventId || 0 : 0;++ let finished = false;++ try {++ const res = await openStream(jobId, lastEventId);++ if (res.status === 204) return; // finished, nothing newer++ await readEventStream(res.body, (e) => {++ let data = null;++ try { data = JSON.parse(e.data); } catch { /* keep null */ }++ if (e.id) saveState({ ...(loadState() || {}), jobId, lastEventId: Number(e.id) });++ onEvent({ id: Number(e.id), event: e.event, job: data });++ if (data && TERMINAL.has(data.state) && e.event !== 'job.progress') finished = true;++ }, (ms) => { retryMs = ms; });++ } catch (err) {++ if (isOpenVibeError(err) && err.status >= 400 && err.status < 500) throw err; // 401/403/404: do not loop++ log.error(`event stream interrupted: ${isOpenVibeError(err) ? err.code : err.message}`);++ }++ if (finished) return;++ if (++reconnects > maxReconnects) throw new Error(`gave up after ${maxReconnects} reconnects`);++ await new Promise((r) => setTimeout(r, retryMs));++ }++ }++++ async function get(jobId) {++ return client.json({ service: 'tools', path: `/api/v1/jobs/${encodeURIComponent(jobId)}` });++ }++++ async function download(job) {++ const saved = [];++ fs.mkdirSync(config.outDir, { recursive: true });++ for (const [n, f] of ((job.result && job.result.files) || []).entries()) {++ const res = await client.request({ service: 'tools', path: `/api/v1/jobs/${encodeURIComponent(job.id)}/files/${n}`, responseType: 'arrayBuffer' });++ const name = path.basename(String(f.name || `result-${n}`)).replace(/[^\w.-]/g, '_');++ const out = path.join(config.outDir, name);++ fs.writeFileSync(out, Buffer.from(res.data));++ saved.push(out);++ }++ return saved;++ }++++ /** submit -> watch -> result -> download; clears the state file once the job is done. */++ async function run(filePath, { onEvent } = {}) {++ const { jobId } = await submit(filePath);++ await watch(jobId, { onEvent });++ const job = await get(jobId);++ const files = job.state === 'succeeded' ? await download(job) : [];++ clearState();++ return { job, files };++ }++++ return { client, submit, watch, get, download, run, loadState };++}++++async function main([file] = process.argv.slice(2)) {++ if (!file) { console.error('usage: node run-job.js <file>'); return 2; }++ try {++ const runner = createJobRunner(loadConfig());++ const { job, files } = await runner.run(file, {++ onEvent: ({ event, job: j }) => {++ const pct = j && j.progress && j.progress.percent != null ? ` ${j.progress.percent}%` : '';++ console.log(`${event}${pct}${j && j.progress && j.progress.message ? ` ${j.progress.message}` : ''}`);++ },++ });++ if (job.state !== 'succeeded') {++ console.error(`job ${job.id} ${job.state}${job.error ? `: ${job.error.code} ${job.error.detail || ''}` : ''}`);++ return 1;++ }++ for (const f of files) console.log(`saved ${f}`);++ return 0;++ } catch (err) {++ console.error(`job failed: ${isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''}` : err.message}`);++ return 1;++ }++}++++if (require.main === module) main().then((code) => { process.exitCode = code; });++++module.exports = { loadConfig, createJobRunner, readEventStream, main };nnew file mode 100644@@@ -0,0 +1,173 @@++'use strict';++/**++ * A small local stand-in for OpenVibe.Tools' job API (/api/v1/jobs), for tests only. The SDK's++ * createMockPlatform() has no Tools, so this mock plays the documented behaviour:++ *++ * POST /api/v1/jobs multipart type/input/file, Idempotency-Key -> 202 (200 + Idempotent-Replayed on repeat)++ * GET /api/v1/jobs/:id the job view++ * GET /api/v1/jobs/:id/events SSE with ids; Last-Event-ID replays only later events; 204 when finished and nothing newer++ * GET /api/v1/jobs/:id/files/:n a result file++ *++ * Tokens are verified against the mock platform's key: RS256, audience openvibe.tools, the++ * tools.job.* capability for the action. Jobs are owner-scoped (the token's sub).++ */++const crypto = require('node:crypto');++const http = require('node:http');++++const ULID = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';++const newJobId = () => `job_${Array.from(crypto.randomBytes(26), (b) => ULID[b & 31]).join('')}`;++const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);++const CAPS = { create: 'tools.job.create', read: 'tools.job.read' };++++function createMockToolsServer({ publicKey, stepMs = 25, dropStreamsAfter = 0 } = {}) {++ const jobs = new Map();++ const idem = new Map();++ const listeners = new Map();++ const stats = { submits: 0, streams: [], replays: 0 };++ let drops = dropStreamsAfter;++++ function verify(req, action) {++ const auth = String(req.headers.authorization || '');++ const parts = auth.startsWith('Bearer ') ? auth.slice(7).split('.') : [];++ if (parts.length !== 3) return { error: [401, 'token.missing'] };++ let header, claims;++ try {++ header = JSON.parse(Buffer.from(parts[0], 'base64url'));++ claims = JSON.parse(Buffer.from(parts[1], 'base64url'));++ } catch { return { error: [401, 'token.malformed'] }; }++ if (header.alg !== 'RS256' || !crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, Buffer.from(parts[2], 'base64url'))) return { error: [401, 'token.bad_signature'] };++ if (!claims.exp || claims.exp * 1000 < Date.now()) return { error: [401, 'token.expired'] };++ if (!(claims.aud || []).includes('openvibe.tools')) return { error: [401, 'token.wrong_audience'] };++ if (!(claims.cap || []).includes(CAPS[action])) return { error: [403, 'capability.denied'] };++ return { owner: claims.sub };++ }++++ function view(job) {++ return {++ id: job.id, object: 'tools.job', service: 'img', type: job.type, type_version: 1, state: job.state,++ progress: { percent: job.percent, message: job.message }, attempts: job.state === 'queued' ? 0 : 1, max_attempts: 2,++ result: job.result ? { files: job.result.files.map((f, i) => ({ name: f.name, mime: f.mime, size: f.bytes.length, url: `/api/v1/jobs/${job.id}/files/${i}` })), data: job.result.data } : null,++ error: null, retryable: false,++ links: { self: `/api/v1/jobs/${job.id}`, events: `/api/v1/jobs/${job.id}/events`, cancel: TERMINAL.has(job.state) ? null : `/api/v1/jobs/${job.id}` },++ };++ }++++ function emit(job, event) {++ const e = { seq: job.events.length + 1, event, data: view(job) };++ job.events.push(e);++ for (const fn of listeners.get(job.id) || []) fn(e);++ }++++ function lifecycle(job) {++ const steps = [++ () => { job.state = 'running'; emit(job, 'job.running'); },++ () => { job.percent = 50; job.message = 'converting'; emit(job, 'job.progress'); },++ () => {++ job.state = 'succeeded'; job.percent = 100; job.message = null;++ job.result = { files: [{ name: job.fileName.replace(/\.[^.]+$/, '') + '.' + (job.input.format || 'out'), mime: `image/${job.input.format || 'png'}`, bytes: Buffer.concat([Buffer.from('converted:'), job.bytes]) }], data: { tool: job.input.tool } };++ emit(job, 'job.succeeded');++ },++ ];++ const next = () => { const s = steps.shift(); if (s) { s(); setTimeout(next, stepMs); } };++ setTimeout(next, stepMs);++ }++++ const problem = (res, status, code, detail) => {++ res.writeHead(status, { 'Content-Type': 'application/problem+json' });++ res.end(JSON.stringify({ type: `https://openvibe.network/problems/${code}`, title: String(status), status, code, detail: detail || code }));++ };++++ const server = http.createServer(async (req, res) => {++ const url = new URL(req.url, 'http://tools.invalid');++ let m;++ if (req.method === 'POST' && url.pathname === '/api/v1/jobs') {++ const who = verify(req, 'create');++ if (who.error) return problem(res, ...who.error);++ const chunks = [];++ for await (const c of req) chunks.push(c);++ const raw = Buffer.concat(chunks);++ const form = await new Request('http://tools.invalid/', { method: 'POST', headers: { 'content-type': req.headers['content-type'] }, body: raw }).formData();++ const key = String(req.headers['idempotency-key'] || '');++ if (key.length < 8 || key.length > 200) return problem(res, 400, 'tools.job.invalid', 'Idempotency-Key must be 8-200 characters');++ // Like Tools: the request is identified by type, input and the files' hashes (not the multipart bytes).++ const fileHashes = [];++ for (const f of form.getAll('file')) if (typeof f !== 'string') fileHashes.push(crypto.createHash('sha256').update(Buffer.from(await f.arrayBuffer())).digest('hex'));++ const hash = crypto.createHash('sha256').update(JSON.stringify([String(form.get('type')), String(form.get('input') || '{}'), fileHashes])).digest('hex');++ const prior = idem.get(`${who.owner}|${key}`);++ if (prior) {++ if (prior.hash !== hash) return problem(res, 409, 'tools.job.idempotency_conflict', 'This Idempotency-Key was used for a different request');++ stats.replays++;++ res.writeHead(200, { 'Content-Type': 'application/json', Location: `/api/v1/jobs/${prior.id}`, 'Idempotent-Replayed': 'true' });++ return res.end(JSON.stringify(view(jobs.get(prior.id))));++ }++ const file = form.get('file');++ if (!file || typeof file === 'string') return problem(res, 400, 'tools.job.invalid', 'one file is required');++ stats.submits++;++ const job = {++ id: newJobId(), owner: who.owner, type: String(form.get('type')), input: JSON.parse(String(form.get('input') || '{}')),++ state: 'queued', percent: null, message: null, events: [], result: null,++ fileName: file.name, bytes: Buffer.from(await file.arrayBuffer()),++ };++ jobs.set(job.id, job);++ idem.set(`${who.owner}|${key}`, { id: job.id, hash });++ emit(job, 'job.queued');++ lifecycle(job);++ res.writeHead(202, { 'Content-Type': 'application/json', Location: `/api/v1/jobs/${job.id}` });++ return res.end(JSON.stringify(view(job)));++ }++ if ((m = url.pathname.match(/^\/api\/v1\/jobs\/([^/]+)(\/events|\/files\/(\d+))?$/)) && req.method === 'GET') {++ const who = verify(req, 'read');++ if (who.error) return problem(res, ...who.error);++ const job = jobs.get(m[1]);++ if (!job || job.owner !== who.owner) return problem(res, 404, 'tools.job.not_found', 'No such job');++ if (!m[2]) {++ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });++ return res.end(JSON.stringify(view(job)));++ }++ if (m[3] !== undefined) {++ if (job.state !== 'succeeded') return problem(res, 409, 'tools.job.not_ready', `The job is ${job.state}`);++ const f = job.result.files[Number(m[3])];++ if (!f) return problem(res, 404, 'tools.job.file_not_found', 'No such result file');++ res.writeHead(200, { 'Content-Type': f.mime, 'Content-Disposition': `attachment; filename="${f.name}"` });++ return res.end(f.bytes);++ }++ const after = Math.max(0, parseInt(req.headers['last-event-id'] || url.searchParams.get('last_event_id') || '0', 10) || 0);++ stats.streams.push(after);++ const pending = job.events.filter((e) => e.seq > after);++ if (TERMINAL.has(job.state) && !pending.length) { res.writeHead(204); return res.end(); }++ res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store' });++ res.write(`retry: ${stepMs}\n\n`);++ let last = after;++ let written = 0;++ const set = listeners.get(job.id) || new Set();++ listeners.set(job.id, set);++ const close = () => { set.delete(write); res.end(); };++ const write = (e) => {++ if (e.seq <= last || res.writableEnded) return;++ last = e.seq;++ res.write(`id: ${e.seq}\nevent: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`);++ written++;++ if (TERMINAL.has(e.data.state) && e.event !== 'job.progress') return setImmediate(close);++ if (drops > 0 && written >= 1) { drops--; setImmediate(close); } // simulate a dropped connection++ };++ set.add(write);++ for (const e of job.events) write(e);++ req.on('close', () => set.delete(write));++ return undefined;++ }++ return problem(res, 404, 'not_found', 'Not found');++ });++++ return {++ server,++ stats,++ jobs,++ async listen() {++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ return `http://127.0.0.1:${server.address().port}`;++ },++ close: () => new Promise((r) => { server.closeAllConnections(); server.close(r); }),++ };++}++++module.exports = { createMockToolsServer };nnew file mode 100644@@@ -0,0 +1,89 @@++'use strict';++/**++ * Smoke test: submit -> watch (with a dropped stream) -> result -> download, reattach after a++ * restart, idempotent resubmission. Network is the SDK's mock platform; Tools is the local mock in++ * this folder (the SDK mock has no Tools). No network.++ */++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const { createMockPlatform } = require('openvibe-sdk/testing');++const { createJobRunner, loadConfig } = require('../run-job');++const { createMockToolsServer } = require('./mock-tools-server');++++const realFetch = globalThis.fetch;++globalThis.fetch = () => { throw new Error('network access in a smoke test'); };++++const APP = 'app_01JEXAMPLETOOLJOB000000000';++const OTHER = 'app_01JANOTHERAPP0000000000000';++const SECRET = 'ovsec_tool_job_test_secret';++const quiet = { log() {}, error() {} };++++(async () => {++ const toolsGrants = [{ capability: 'tools.job.create', audience: 'openvibe.tools' }, { capability: 'tools.job.read', audience: 'openvibe.tools' }];++ const platform = createMockPlatform({++ clients: { [APP]: { secret: SECRET, grants: toolsGrants }, [OTHER]: { secret: 'o', grants: toolsGrants }, app_01JNOGRANT0000000000000000: { secret: 'n', grants: [] } },++ });++ const tools = createMockToolsServer({ publicKey: platform.keys.publicKey, dropStreamsAfter: 1 });++ const toolsUrl = await tools.listen();++ // Network calls go to the mock platform, Tools calls to the local mock server.++ const fetch = (input, init) => (String(input && input.url ? input.url : input).startsWith(toolsUrl) ? realFetch(input, init) : platform.fetch(input, init));++++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-tooljob-'));++ const file = path.join(dir, 'photo.png');++ fs.writeFileSync(file, Buffer.from('not really a png, the mock does not care'));++ const env = { OV_CLIENT_ID: APP, OV_CLIENT_SECRET: SECRET, OV_TOOLS_JOBS_URL: toolsUrl, OV_OUT_DIR: path.join(dir, 'out'), OV_JOB_STATE: path.join(dir, 'state.json') };++++ // 1. The whole flow, with the event stream dropped after its first event.++ const seen = [];++ const runner = createJobRunner(loadConfig(env), { fetch, log: quiet });++ const { job, files } = await runner.run(file, { onEvent: (e) => seen.push(e) });++ assert.equal(job.state, 'succeeded');++ assert.deepEqual(seen.map((e) => e.event), ['job.queued', 'job.running', 'job.progress', 'job.succeeded']);++ assert.deepEqual(seen.map((e) => e.id), [1, 2, 3, 4], 'every event once, in order');++ assert.deepEqual(tools.stats.streams.slice(0, 2), [0, 1], 'reconnected with Last-Event-ID: 1');++ assert.equal(files.length, 1);++ assert.equal(path.basename(files[0]), 'photo.webp');++ assert.equal(fs.readFileSync(files[0], 'utf8'), 'converted:not really a png, the mock does not care');++ assert.equal(fs.existsSync(env.OV_JOB_STATE), false, 'state cleared once done');++ assert.equal(tools.stats.submits, 1);++++ // 2. Crash after submitting: a new process with the same state file reattaches, no second job.++ const file2 = path.join(dir, 'second.png');++ fs.writeFileSync(file2, 'second image');++ const crashed = createJobRunner(loadConfig(env), { fetch, log: quiet });++ const first = await crashed.submit(file2);++ assert.equal(first.reattached, false);++ const restarted = createJobRunner(loadConfig(env), { fetch, log: quiet });++ const again = await restarted.run(file2);++ assert.equal(again.job.id, first.jobId);++ assert.equal(tools.stats.submits, 2, 'reattached instead of resubmitting');++++ // 3. Lost state file: the Idempotency-Key is derived from the request, so Tools replays the same job.++ const lost = createJobRunner(loadConfig(env), { fetch, log: quiet });++ const replay = await lost.submit(file2);++ assert.equal(replay.jobId, first.jobId);++ assert.equal(replay.replayed, true);++ assert.equal(tools.stats.submits, 2);++++ // 4. A finished job with nothing newer answers 204: watch() returns at once.++ const t0 = Date.now();++ await lost.watch(first.jobId);++ assert.ok(Date.now() - t0 < 1000);++++ // 5. Jobs are owner-scoped: another app gets 404, and a 4xx is not retried in a loop.++ const other = createJobRunner(loadConfig({ ...env, OV_CLIENT_ID: OTHER, OV_CLIENT_SECRET: 'o', OV_JOB_STATE: path.join(dir, 'other.json') }), { fetch, log: quiet });++ await assert.rejects(other.get(first.jobId), (err) => err.status === 404 && err.code === 'tools.job.not_found');++ await assert.rejects(other.watch(first.jobId), (err) => err.status === 404);++++ // 6. No grant: Network refuses the token, nothing reaches Tools.++ const before = tools.stats.submits;++ const nogrant = createJobRunner(loadConfig({ ...env, OV_CLIENT_ID: 'app_01JNOGRANT0000000000000000', OV_CLIENT_SECRET: 'n' }), { fetch, log: quiet });++ await assert.rejects(nogrant.submit(path.join(dir, 'photo.png')), (err) => err.code === 'invalid_scope');++ assert.equal(tools.stats.submits, before);++++ await tools.close();++ fs.rmSync(dir, { recursive: true, force: true });++ console.log('tool-job: ok');++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,14 @@++# server.js: the subscription's signing secret (subscribe.js writes it to .env.webhook).++OV_WEBHOOK_SECRET=++# While rotating: the previous secret, accepted until you remove it.++OV_WEBHOOK_SECRET_PREVIOUS=++OV_WEBHOOK_PATH=/webhooks/openvibe++OV_DB_PATH=./data/webhook-consumer.db++OV_PORT=3003++++# subscribe.js: an app holding events.subscription.manage (audience openvibe.events).++OV_NETWORK_URL=https://openvibe.network++OV_CLIENT_ID=++OV_CLIENT_SECRET=++# Optional: an explicit Events origin instead of the one the registry advertises.++OV_EVENTS_URL=nnew file mode 100644@@@ -0,0 +1,60 @@++# Webhook consumer++++Receive OpenVibe.Events deliveries over HTTPS, verify `X-OpenVibe-Signature`, and handle every++event **exactly once** even though delivery is at least once.++++```bash++node --env-file=.env subscribe.js 'media.object.*' https://hooks.example.com/webhooks/openvibe++node --env-file=.env --env-file=.env.webhook server.js # POST /webhooks/openvibe on :3003++```++++## What it proves++++- **Signature first, on the raw bytes.** `openvibe-sdk/events` `parseDelivery()` checks++ `sha256=<HMAC-SHA256 of the raw body>` in constant time before anything is parsed. A wrong++ secret, a changed byte or a missing header is `401`, with no detail.++- **Exactly once.** `createInbox(db).once(consumer, event_id, fn)` writes the receipt and runs your++ handler in one SQLite transaction. A redelivery (a retry after a lost `2xx`, a replay) is++ answered `200 { duplicate: true }` without running the handler. A handler that throws leaves++ no receipt, the consumer answers `500`, and Events retries with backoff.++- **The signed body is the truth.** `X-OpenVibe-Event-Id` must match the body's `event_id`.++- **Secret rotation** without downtime: `OV_WEBHOOK_SECRET_PREVIOUS` is accepted while it is set.++- `subscribe.js` creates the subscription with `events.subscriptions.create()`. It generates the++ signing secret itself and writes it to `.env.webhook` (mode 0600); it never prints it.++++## Files++++| File | What |++|---|---|++| `server.js` | `createConsumer()`: the HTTP endpoint, signature check, inbox, `received_events` table |++| `subscribe.js` | `createSubscription()`: create the Events subscription with an app token |++| `test/smoke.test.js` | subscription via the SDK mock, then deliveries signed and shaped exactly like the Events delivery worker's |++++## Run the smoke test++++```bash++npm test++```++++The SDK's mock platform stores subscriptions but has no delivery worker, so the test plays the++worker: it signs each body with `signDelivery()` and sends the same headers Events sends++(`X-OpenVibe-Event-Id`, `-Event-Type`, `-Seq`, `-Subscription-Id`, `-Delivery-Attempt`, `-Signature`).++++## Run it against the real platform++++**This cannot be demonstrated against the real platform yet.** The consumer side is ready, but a++developer app cannot create a subscription:++++- `events.subscription.manage` (and every other `events.*` capability) has visibility `internal`++ in openvibe-contracts, so Network never grants it to an app.++- OpenVibe.Events' public host exposes only `/realtime/stream` and health; its subscription API++ is reachable from the platform's own hosts only.++- The Events delivery worker only POSTs to endpoint hosts on its allow-list.++++When those change, the steps are: an app with `events.subscription.manage`, `cp .env.example .env`,++`node --env-file=.env subscribe.js '<topic pattern>' https://<your host>/webhooks/openvibe`, then run++`server.js` behind HTTPS with `--env-file=.env.webhook`.++++Signature scheme note: the HMAC covers the body only, with no timestamp, so a captured delivery++can be replayed later. The inbox makes a replay harmless (it is a duplicate), which is why the++inbox is not optional.nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "name": "openvibe-example-webhook-consumer",++ "version": "0.1.0",++ "private": true,++ "description": "Webhook consumer: verify X-OpenVibe-Signature and process each event exactly once through an inbox.",++ "license": "MIT",++ "main": "server.js",++ "scripts": {++ "start": "node --env-file-if-exists=.env server.js",++ "test": "node test/smoke.test.js"++ },++ "engines": {++ "node": ">=22"++ },++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "better-sqlite3": "^11.10.0"++ }++}nnew file mode 100644@@@ -0,0 +1,137 @@++#!/usr/bin/env node++'use strict';++/**++ * Webhook consumer: receives OpenVibe.Events deliveries, verifies their signature and handles each++ * event exactly once.++ *++ * node --env-file=.env server.js # POST http://localhost:3003/webhooks/openvibe++ *++ * A delivery is POSTed as { "event": <events.event-envelope@1>, "seq": <n> } with++ * X-OpenVibe-Signature: sha256=<HMAC-SHA256 of the raw body with the subscription secret>.++ *++ * 1. Read the RAW body (the signature covers the exact bytes; never re-serialize JSON first).++ * 2. openvibe-sdk/events parseDelivery() checks the signature in constant time and parses it.++ * A bad signature is 401 and nothing else is said.++ * 3. Delivery is at least once (retries, replays). inbox.once() records the event id and runs++ * the handler in ONE SQLite transaction, so a repeat is answered 200 without running it again,++ * and a handler that throws leaves no receipt: the 500 makes Events retry later.++ *++ * Secret rotation: set OV_WEBHOOK_SECRET_PREVIOUS to the old secret while both are in use.++ */++const fs = require('node:fs');++const http = require('node:http');++const path = require('node:path');++const Database = require('better-sqlite3');++const { parseDelivery, createInbox } = require('openvibe-sdk/events');++++const MAX_BODY = 1024 * 1024;++const CONSUMER = 'webhook-consumer';++++function loadConfig(env = process.env) {++ if (!env.OV_WEBHOOK_SECRET) {++ const err = new Error('missing environment variable: OV_WEBHOOK_SECRET (see .env.example)');++ err.code = 'config.missing';++ throw err;++ }++ return {++ secrets: [env.OV_WEBHOOK_SECRET, env.OV_WEBHOOK_SECRET_PREVIOUS].filter(Boolean),++ dbPath: env.OV_DB_PATH || path.join(__dirname, 'data', 'webhook-consumer.db'),++ port: Number(env.OV_PORT || 3003),++ path: env.OV_WEBHOOK_PATH || '/webhooks/openvibe',++ };++}++++function openDatabase(dbPath) {++ if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(dbPath), { recursive: true });++ const db = new Database(dbPath);++ db.pragma('journal_mode = WAL');++ db.exec(`CREATE TABLE IF NOT EXISTS received_events (++ event_id TEXT PRIMARY KEY,++ event_type TEXT NOT NULL,++ source TEXT NOT NULL,++ seq INTEGER,++ subject TEXT,++ received_at TEXT NOT NULL++ )`);++ return db;++}++++/**++ * createConsumer(config, { db, handle, log })++ * handle(event, { seq, attempt }) runs inside the inbox transaction and must be synchronous++ * (better-sqlite3). Put side effects that need the network in your own outbox table instead.++ */++function createConsumer(config, { db = openDatabase(config.dbPath), handle = () => {}, log = console } = {}) {++ const inbox = createInbox(db);++ inbox.ensureSchema();++ const record = db.prepare('INSERT INTO received_events (event_id, event_type, source, seq, subject, received_at) VALUES (?, ?, ?, ?, ?, ?)');++++ function verify(raw, headers) {++ for (const secret of config.secrets) {++ const d = parseDelivery(raw, headers, secret);++ if (d) return d;++ }++ return null;++ }++++ function receive(raw, headers) {++ const delivery = verify(raw, headers);++ if (!delivery) return { status: 401, body: { error: 'bad_signature' } };++ const { event, seq, attempt } = delivery;++ if (!event || typeof event.event_id !== 'string' || typeof event.event_type !== 'string') return { status: 400, body: { error: 'bad_envelope' } };++ // The header is informational; the signed body is the truth. They must agree.++ const headerId = headers['x-openvibe-event-id'];++ if (headerId && headerId !== event.event_id) return { status: 400, body: { error: 'event_id_mismatch' } };++ const out = inbox.once(CONSUMER, event.event_id, () => {++ record.run(event.event_id, event.event_type, String(event.source || ''), Number.isFinite(seq) ? seq : null,++ event.subject ? `${event.subject.type}:${event.subject.id}` : null, new Date().toISOString());++ return handle(event, { seq, attempt });++ });++ log.log(`[webhook] ${event.event_type} ${event.event_id} seq=${seq} attempt=${attempt}${out.duplicate ? ' (duplicate, skipped)' : ''}`);++ return { status: 200, body: { ok: true, duplicate: out.duplicate } };++ }++++ const server = http.createServer((req, res) => {++ const send = (status, body) => {++ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });++ res.end(JSON.stringify(body));++ };++ const url = new URL(req.url, 'http://consumer.invalid');++ if (req.method === 'GET' && url.pathname === '/healthz') return send(200, { ok: true });++ if (req.method !== 'POST' || url.pathname !== config.path) return send(404, { error: 'not_found' });++ const chunks = [];++ let size = 0;++ let tooLarge = false;++ req.on('data', (c) => {++ size += c.length;++ if (size > MAX_BODY) { tooLarge = true; chunks.length = 0; return; }++ chunks.push(c);++ });++ req.on('end', () => {++ if (tooLarge) return send(413, { error: 'too_large' });++ try {++ const out = receive(Buffer.concat(chunks), req.headers);++ send(out.status, out.body);++ } catch (err) {++ // No receipt was written: Events retries this delivery with backoff.++ log.error(`[webhook] handler failed: ${err.message}`);++ send(500, { error: 'handler_failed' });++ }++ });++ });++++ return { server, db, inbox, receive };++}++++if (require.main === module) {++ let config;++ try { config = loadConfig(); } catch (err) { console.error(err.message); process.exit(2); }++ const { server } = createConsumer(config, {++ handle(event) {++ // Your side effect goes here (synchronous, same transaction as the receipt).++ },++ });++ server.listen(config.port, () => console.log(`webhook-consumer: POST http://localhost:${config.port}${config.path}`));++}++++module.exports = { loadConfig, openDatabase, createConsumer, CONSUMER };nnew file mode 100644@@@ -0,0 +1,73 @@++#!/usr/bin/env node++'use strict';++/**++ * Create the OpenVibe.Events subscription that delivers to this consumer.++ *++ * node --env-file=.env subscribe.js 'media.object.*' https://hooks.example.com/webhooks/openvibe++ *++ * Needs an app token for openvibe.events holding events.subscription.manage. The signing secret++ * is generated here and sent with the request, then written to .env.webhook (mode 0600) for++ * server.js to read. It is never printed.++ */++const crypto = require('node:crypto');++const fs = require('node:fs');++const path = require('node:path');++const { createClient, isOpenVibeError } = require('openvibe-sdk/core');++const { createServiceTokenClient } = require('openvibe-sdk/auth');++const { createEventsClient } = require('openvibe-sdk/events');++++function loadConfig(env = process.env) {++ const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET'].filter((k) => !env[k]);++ if (missing.length) {++ const err = new Error(`missing environment variables: ${missing.join(', ')} (see .env.example)`);++ err.code = 'config.missing';++ throw err;++ }++ return {++ network: env.OV_NETWORK_URL || 'https://openvibe.network',++ clientId: env.OV_CLIENT_ID,++ clientSecret: env.OV_CLIENT_SECRET,++ eventsUrl: env.OV_EVENTS_URL || null,++ };++}++++/** -> { id, topic_pattern, endpoint, enabled } plus the secret it was created with (kept out of logs). */++async function createSubscription(config, { topicPattern, endpoint, secret = `whsec_${crypto.randomBytes(32).toString('hex')}` }, { fetch } = {}) {++ if (!/^https:\/\//.test(endpoint) && !/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?\//.test(endpoint)) {++ throw new TypeError('the endpoint must be an https:// URL');++ }++ const client = createClient({++ network: config.network,++ fetch,++ tokenProvider: createServiceTokenClient({ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch }),++ baseUrls: config.eventsUrl ? { events: config.eventsUrl } : undefined,++ });++ const events = createEventsClient(client, { source: config.clientId });++ const sub = await events.subscriptions.create({ topicPattern, endpoint, secret });++ return { subscription: { id: sub.id, topic_pattern: sub.topic_pattern, endpoint: sub.endpoint, enabled: sub.enabled }, secret: sub.secret || secret };++}++++async function main([topicPattern, endpoint] = process.argv.slice(2)) {++ if (!topicPattern || !endpoint) {++ console.error('usage: node subscribe.js <topic-pattern> <https endpoint>');++ return 2;++ }++ try {++ const { subscription, secret } = await createSubscription(loadConfig(), { topicPattern, endpoint });++ const file = path.join(__dirname, '.env.webhook');++ fs.writeFileSync(file, `OV_WEBHOOK_SECRET=${secret}\n`, { mode: 0o600 });++ console.log(JSON.stringify(subscription, null, 2));++ console.log(`signing secret written to ${file} (start the consumer with --env-file=.env.webhook)`);++ return 0;++ } catch (err) {++ console.error(`subscribe failed: ${isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''}` : err.message}`);++ if (isOpenVibeError(err) && ['invalid_scope', 'invalid_target', 'capability.denied'].includes(err.code)) {++ console.error('events.subscription.manage is not grantable to developer apps today; see the README.');++ }++ return 1;++ }++}++++if (require.main === module) main().then((code) => { process.exitCode = code; });++++module.exports = { loadConfig, createSubscription, main };nnew file mode 100644@@@ -0,0 +1,142 @@++'use strict';++/**++ * Smoke test: subscription creation against the SDK's mock platform, then deliveries shaped and++ * signed exactly like OpenVibe.Events' delivery worker sends them (the mock has no delivery++ * worker, so this test plays that part with openvibe-sdk/events signDelivery()).++ */++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const { createMockPlatform } = require('openvibe-sdk/testing');++const { signDelivery } = require('openvibe-sdk/events');++const { ulid } = require('openvibe-sdk/core');++const { createConsumer, loadConfig, openDatabase } = require('../server');++const { createSubscription } = require('../subscribe');++++const realFetch = globalThis.fetch;++globalThis.fetch = () => { throw new Error('network access in a smoke test'); };++++const APP = 'app_01JEXAMPLEWEBHOOK000000000';++const SECRET = 'ovsec_webhook_consumer_test_secret';++++function envelope(type = 'media.object.uploaded') {++ return {++ event_id: `evt_${ulid()}`, event_type: type, version: 1, source: 'media', timestamp: new Date().toISOString(),++ actor: { type: 'service', id: 'media' }, subject: { type: 'object', id: 'med_01JEXAMPLEOBJECT0000000000' }, payload: { size: 12 },++ };++}++++/** POST a delivery the way the Events worker does. */++async function deliver(port, event, seq, secret, { attempt = 1, tamper, headers = {} } = {}) {++ const body = JSON.stringify({ event, seq });++ const sent = tamper ? tamper(body) : body;++ const res = await realFetch(`http://127.0.0.1:${port}/webhooks/openvibe`, {++ method: 'POST',++ headers: {++ 'Content-Type': 'application/json',++ 'X-OpenVibe-Event-Id': event.event_id,++ 'X-OpenVibe-Event-Type': event.event_type,++ 'X-OpenVibe-Seq': String(seq),++ 'X-OpenVibe-Subscription-Id': 'sub_test',++ 'X-OpenVibe-Delivery-Attempt': String(attempt),++ 'X-OpenVibe-Signature': signDelivery(body, secret),++ ...headers,++ },++ body: sent,++ });++ return { status: res.status, body: await res.json() };++}++++(async () => {++ // 1. Create the subscription (events.subscription.manage) and keep the secret it was created with.++ const platform = createMockPlatform({++ clients: { [APP]: { secret: SECRET, grants: [{ capability: 'events.subscription.manage', audience: 'openvibe.events' }] } },++ });++ const created = await createSubscription(++ { network: 'https://openvibe.network', clientId: APP, clientSecret: SECRET },++ { topicPattern: 'media.object.*', endpoint: 'https://hooks.example.com/webhooks/openvibe' },++ { fetch: platform.fetch },++ );++ assert.match(created.subscription.id, /^sub_/);++ assert.equal(created.subscription.topic_pattern, 'media.object.*');++ assert.equal('secret' in created.subscription, false, 'the printable part never carries the secret');++ assert.match(created.secret, /^whsec_[0-9a-f]{64}$/);++ await assert.rejects(createSubscription({ network: 'https://openvibe.network', clientId: APP, clientSecret: SECRET },++ { topicPattern: 'x.y.*', endpoint: 'http://hooks.example.com/x' }, { fetch: platform.fetch }), /https/);++++ // 2. Run the consumer with that secret.++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-webhook-'));++ const config = loadConfig({ OV_WEBHOOK_SECRET: created.secret, OV_DB_PATH: path.join(dir, 'consumer.db') });++ const handled = [];++ let failNext = false;++ const logs = [];++ const consumer = createConsumer(config, {++ db: openDatabase(config.dbPath),++ handle(event) {++ if (failNext) { failNext = false; throw new Error('downstream unavailable'); }++ handled.push(event.event_id);++ },++ log: { log: (m) => logs.push(m), error: (m) => logs.push(m) },++ });++ await new Promise((r) => consumer.server.listen(0, '127.0.0.1', r));++ const { port } = consumer.server.address();++++ const e1 = envelope();++ let r = await deliver(port, e1, 1, created.secret);++ assert.deepEqual(r, { status: 200, body: { ok: true, duplicate: false } });++ assert.deepEqual(handled, [e1.event_id]);++++ // Redelivery (a retry after a lost 2xx, or a replay): acknowledged, not processed again.++ r = await deliver(port, e1, 1, created.secret, { attempt: 2 });++ assert.deepEqual(r, { status: 200, body: { ok: true, duplicate: true } });++ assert.deepEqual(handled, [e1.event_id]);++++ // Wrong secret, tampered body, missing signature: 401 and no side effect.++ const e2 = envelope();++ assert.equal((await deliver(port, e2, 2, 'whsec_not_the_secret')).status, 401);++ assert.equal((await deliver(port, e2, 2, created.secret, { tamper: (b) => b.replace('"size":12', '"size":13') })).status, 401);++ assert.equal((await deliver(port, e2, 2, created.secret, { headers: { 'X-OpenVibe-Signature': '' } })).status, 401);++ assert.equal(handled.length, 1);++++ // Header and signed body disagree on the event id: refused.++ assert.equal((await deliver(port, e2, 2, created.secret, { headers: { 'X-OpenVibe-Event-Id': 'evt_01JOTHER0000000000000000000' } })).status, 400);++++ // A handler failure leaves no receipt: 500 now, processed on the retry.++ failNext = true;++ assert.equal((await deliver(port, e2, 2, created.secret)).status, 500);++ assert.equal(consumer.inbox.seen('webhook-consumer', e2.event_id), false);++ r = await deliver(port, e2, 2, created.secret, { attempt: 2 });++ assert.equal(r.status, 200);++ assert.deepEqual(handled, [e1.event_id, e2.event_id]);++++ // Secret rotation: deliveries signed with the previous secret are accepted while it is configured.++ consumer.server.close();++ const rotated = createConsumer(loadConfig({ OV_WEBHOOK_SECRET: 'whsec_new', OV_WEBHOOK_SECRET_PREVIOUS: created.secret, OV_DB_PATH: config.dbPath }), {++ db: consumer.db, handle: (event) => handled.push(event.event_id), log: { log() {}, error() {} },++ });++ await new Promise((res) => rotated.server.listen(0, '127.0.0.1', res));++ const p2 = rotated.server.address().port;++ const e3 = envelope();++ assert.equal((await deliver(p2, e3, 3, created.secret)).status, 200);++ const e4 = envelope();++ assert.equal((await deliver(p2, e4, 4, 'whsec_new')).status, 200);++ assert.equal((await deliver(p2, e4, 4, 'whsec_new')).body.duplicate, true);++ assert.deepEqual(handled, [e1.event_id, e2.event_id, e3.event_id, e4.event_id]);++++ // The receipt and the app's own row were written together.++ const rows = consumer.db.prepare('SELECT event_id FROM received_events ORDER BY seq').all().map((x) => x.event_id);++ assert.deepEqual(rows, handled);++++ // Oversized bodies are refused before parsing.++ const big = await realFetch(`http://127.0.0.1:${p2}/webhooks/openvibe`, { method: 'POST', body: 'x'.repeat(1024 * 1024 + 10) });++ assert.equal(big.status, 413);++++ // No secret in the logs.++ assert.ok(!logs.join('\n').includes(created.secret));++++ rotated.server.close();++ consumer.db.close();++ fs.rmSync(dir, { recursive: true, force: true });++ console.log('webhook-consumer: ok');++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,725 @@++{++ "name": "openvibe-examples",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-examples",++ "version": "0.1.0",++ "license": "MIT",++ "workspaces": [++ "examples/*"++ ],++ "engines": {++ "node": ">=22"++ }++ },++ "examples/browser-app": {++ "name": "openvibe-example-browser-app",++ "version": "0.1.0",++ "license": "MIT",++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ },++ "engines": {++ "node": ">=22"++ }++ },++ "examples/chat-bot": {++ "name": "openvibe-example-chat-bot",++ "version": "0.1.0",++ "license": "MIT",++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ },++ "devDependencies": {++ "ws": "^8.18.0"++ },++ "engines": {++ "node": ">=22"++ }++ },++ "examples/event-subscriber": {++ "name": "openvibe-example-event-subscriber",++ "version": "0.1.0",++ "license": "MIT",++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ },++ "engines": {++ "node": ">=22"++ }++ },++ "examples/media-uploader": {++ "name": "openvibe-example-media-uploader",++ "version": "0.1.0",++ "license": "MIT",++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ },++ "engines": {++ "node": ">=22"++ }++ },++ "examples/mod-manifest": {++ "name": "openvibe-example-mod-manifest",++ "version": "0.1.0",++ "license": "MIT",++ "dependencies": {++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ },++ "engines": {++ "node": ">=22"++ }++ },++ "examples/node-server-app": {++ "name": "openvibe-example-node-server-app",++ "version": "0.1.0",++ "license": "MIT",++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ },++ "engines": {++ "node": ">=22"++ }++ },++ "examples/oauth-app": {++ "name": "openvibe-example-oauth-app",++ "version": "0.1.0",++ "license": "MIT",++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ },++ "engines": {++ "node": ">=22"++ }++ },++ "examples/tool-job": {++ "name": "openvibe-example-tool-job",++ "version": "0.1.0",++ "license": "MIT",++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ },++ "engines": {++ "node": ">=22"++ }++ },++ "examples/webhook-consumer": {++ "name": "openvibe-example-webhook-consumer",++ "version": "0.1.0",++ "license": "MIT",++ "dependencies": {++ "better-sqlite3": "^11.10.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ },++ "engines": {++ "node": ">=22"++ }++ },++ "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/base64-js": {++ "version": "1.5.1",++ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",++ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",++ "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/better-sqlite3": {++ "version": "11.10.0",++ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",++ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",++ "hasInstallScript": true,++ "license": "MIT",++ "dependencies": {++ "bindings": "^1.5.0",++ "prebuild-install": "^7.1.1"++ }++ },++ "node_modules/bindings": {++ "version": "1.5.0",++ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",++ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",++ "license": "MIT",++ "dependencies": {++ "file-uri-to-path": "1.0.0"++ }++ },++ "node_modules/bl": {++ "version": "4.1.0",++ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",++ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",++ "license": "MIT",++ "dependencies": {++ "buffer": "^5.5.0",++ "inherits": "^2.0.4",++ "readable-stream": "^3.4.0"++ }++ },++ "node_modules/buffer": {++ "version": "5.7.1",++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",++ "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",++ "dependencies": {++ "base64-js": "^1.3.1",++ "ieee754": "^1.1.13"++ }++ },++ "node_modules/chownr": {++ "version": "1.1.4",++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",++ "license": "ISC"++ },++ "node_modules/decompress-response": {++ "version": "6.0.0",++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",++ "license": "MIT",++ "dependencies": {++ "mimic-response": "^3.1.0"++ },++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/deep-extend": {++ "version": "0.6.0",++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",++ "license": "MIT",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/detect-libc": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=8"++ }++ },++ "node_modules/end-of-stream": {++ "version": "1.4.5",++ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",++ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",++ "license": "MIT",++ "dependencies": {++ "once": "^1.4.0"++ }++ },++ "node_modules/expand-template": {++ "version": "2.0.3",++ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",++ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",++ "license": "(MIT OR WTFPL)",++ "engines": {++ "node": ">=6"++ }++ },++ "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.8",++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fastify"++ },++ {++ "type": "opencollective",++ "url": "https://opencollective.com/fastify"++ }++ ],++ "license": "BSD-3-Clause"++ },++ "node_modules/file-uri-to-path": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",++ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",++ "license": "MIT"++ },++ "node_modules/fs-constants": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",++ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",++ "license": "MIT"++ },++ "node_modules/github-from-package": {++ "version": "0.0.0",++ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",++ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",++ "license": "MIT"++ },++ "node_modules/ieee754": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",++ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",++ "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": "BSD-3-Clause"++ },++ "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/ini": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",++ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",++ "license": "ISC"++ },++ "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/mimic-response": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",++ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",++ "license": "MIT",++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/minimist": {++ "version": "1.2.8",++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/mkdirp-classic": {++ "version": "0.5.3",++ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",++ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",++ "license": "MIT"++ },++ "node_modules/napi-build-utils": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",++ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",++ "license": "MIT"++ },++ "node_modules/node-abi": {++ "version": "3.96.0",++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",++ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",++ "license": "MIT",++ "dependencies": {++ "semver": "^7.3.5"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/once": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",++ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",++ "license": "ISC",++ "dependencies": {++ "wrappy": "1"++ }++ },++ "node_modules/openvibe-contracts": {++ "version": "0.26.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0",++ "integrity": "sha512-yigHjNzLIyK578V0Weqv2zL6z8PO8ce3HRXPa/61n1XK5Y7pTi28qHMMiO6stboQjTziN1+8wMeByoCF1sGCnw==",++ "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-example-browser-app": {++ "resolved": "examples/browser-app",++ "link": true++ },++ "node_modules/openvibe-example-chat-bot": {++ "resolved": "examples/chat-bot",++ "link": true++ },++ "node_modules/openvibe-example-event-subscriber": {++ "resolved": "examples/event-subscriber",++ "link": true++ },++ "node_modules/openvibe-example-media-uploader": {++ "resolved": "examples/media-uploader",++ "link": true++ },++ "node_modules/openvibe-example-mod-manifest": {++ "resolved": "examples/mod-manifest",++ "link": true++ },++ "node_modules/openvibe-example-node-server-app": {++ "resolved": "examples/node-server-app",++ "link": true++ },++ "node_modules/openvibe-example-oauth-app": {++ "resolved": "examples/oauth-app",++ "link": true++ },++ "node_modules/openvibe-example-tool-job": {++ "resolved": "examples/tool-job",++ "link": true++ },++ "node_modules/openvibe-example-webhook-consumer": {++ "resolved": "examples/webhook-consumer",++ "link": true++ },++ "node_modules/openvibe-sdk": {++ "version": "0.2.2",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",++ "license": "MIT",++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/prebuild-install": {++ "version": "7.1.3",++ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",++ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",++ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",++ "license": "MIT",++ "dependencies": {++ "detect-libc": "^2.0.0",++ "expand-template": "^2.0.3",++ "github-from-package": "0.0.0",++ "minimist": "^1.2.3",++ "mkdirp-classic": "^0.5.3",++ "napi-build-utils": "^2.0.0",++ "node-abi": "^3.3.0",++ "pump": "^3.0.0",++ "rc": "^1.2.7",++ "simple-get": "^4.0.0",++ "tar-fs": "^2.0.0",++ "tunnel-agent": "^0.6.0"++ },++ "bin": {++ "prebuild-install": "bin.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/pump": {++ "version": "3.0.4",++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",++ "license": "MIT",++ "dependencies": {++ "end-of-stream": "^1.1.0",++ "once": "^1.3.1"++ }++ },++ "node_modules/rc": {++ "version": "1.2.8",++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",++ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",++ "dependencies": {++ "deep-extend": "^0.6.0",++ "ini": "~1.3.0",++ "minimist": "^1.2.0",++ "strip-json-comments": "~2.0.1"++ },++ "bin": {++ "rc": "cli.js"++ }++ },++ "node_modules/readable-stream": {++ "version": "3.6.2",++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",++ "license": "MIT",++ "dependencies": {++ "inherits": "^2.0.3",++ "string_decoder": "^1.1.1",++ "util-deprecate": "^1.0.1"++ },++ "engines": {++ "node": ">= 6"++ }++ },++ "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/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/simple-concat": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",++ "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/simple-get": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",++ "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",++ "dependencies": {++ "decompress-response": "^6.0.0",++ "once": "^1.3.1",++ "simple-concat": "^1.0.0"++ }++ },++ "node_modules/string_decoder": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",++ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "~5.2.0"++ }++ },++ "node_modules/strip-json-comments": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",++ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/tar-fs": {++ "version": "2.1.5",++ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",++ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",++ "license": "MIT",++ "dependencies": {++ "chownr": "^1.1.1",++ "mkdirp-classic": "^0.5.2",++ "pump": "^3.0.0",++ "tar-stream": "^2.1.4"++ }++ },++ "node_modules/tar-stream": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",++ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",++ "license": "MIT",++ "dependencies": {++ "bl": "^4.0.3",++ "end-of-stream": "^1.4.1",++ "fs-constants": "^1.0.0",++ "inherits": "^2.0.3",++ "readable-stream": "^3.1.1"++ },++ "engines": {++ "node": ">=6"++ }++ },++ "node_modules/tunnel-agent": {++ "version": "0.6.0",++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ },++ "engines": {++ "node": "*"++ }++ },++ "node_modules/util-deprecate": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",++ "license": "MIT"++ },++ "node_modules/wrappy": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",++ "license": "ISC"++ },++ "node_modules/ws": {++ "version": "8.21.3",++ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",++ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">=10.0.0"++ },++ "peerDependencies": {++ "bufferutil": "^4.0.1",++ "utf-8-validate": ">=5.0.2"++ },++ "peerDependenciesMeta": {++ "bufferutil": {++ "optional": true++ },++ "utf-8-validate": {++ "optional": true++ }++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,17 @@++{++ "name": "openvibe-examples",++ "version": "0.1.0",++ "private": true,++ "description": "Executable public integration examples for the OpenVibe platform: SDK, scoped credentials and public APIs only.",++ "license": "MIT",++ "repository": "github:OpenVibers/OpenVibe.Examples",++ "workspaces": [++ "examples/*"++ ],++ "scripts": {++ "test": "node test/run.js"++ },++ "engines": {++ "node": ">=22"++ }++}nnew file mode 100644@@@ -0,0 +1,61 @@++'use strict';++/**++ * The examples use public surfaces only. Fails on:++ * - the loopback shared-key header used between first-party services;++ * - paths under the platform's internal route prefix;++ * - hard-coded loopback service ports (127.0.0.1:<digits>, localhost:4xxx = the platform's++ * service port range), except in the local test mocks (test/mock-*.js);++ * - a secret-looking value in any .env.example.++ * Scans every file in the repository except node_modules, .git and this file.++ */++const assert = require('node:assert/strict');++const fs = require('node:fs');++const path = require('node:path');++++const ROOT = path.join(__dirname, '..');++const SELF = __filename;++const SKIP_DIRS = new Set(['node_modules', '.git']);++const TEXT = /\.(js|mjs|cjs|json|md|html|css|yml|yaml|txt)$|\.env\.example$|^\.gitignore$/;++++function walk(dir, out = []) {++ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {++ if (SKIP_DIRS.has(entry.name)) continue;++ const p = path.join(dir, entry.name);++ if (entry.isDirectory()) walk(p, out);++ else if (TEXT.test(entry.name) && p !== SELF && entry.name !== 'package-lock.json') out.push(p);++ }++ return out;++}++++const RULES = [++ { name: 'internal key header', re: new RegExp(['X', 'Internal', 'Key'].join('-'), 'i') },++ { name: 'internal route', re: new RegExp(`/${'internal'}/`) },++ { name: 'loopback service port', re: new RegExp(`127\\.0\\.0\\.1:\\d|localhost:4\\d{3}\\b`), allow: (rel) => /(^|\/)test\/mock-[\w-]+\.js$/.test(rel) },++];++++const files = walk(ROOT);++assert.ok(files.length > 20, 'the scan found the examples');++const problems = [];++for (const file of files) {++ const rel = path.relative(ROOT, file).split(path.sep).join('/');++ const lines = fs.readFileSync(file, 'utf8').split('\n');++ lines.forEach((line, i) => {++ for (const rule of RULES) {++ if (rule.re.test(line) && !(rule.allow && rule.allow(rel))) problems.push(`${rel}:${i + 1} ${rule.name}: ${line.trim().slice(0, 120)}`);++ }++ });++ if (rel.endsWith('.env.example')) {++ lines.forEach((line, i) => {++ const m = line.match(/^\s*(OV_[A-Z_]*(SECRET|TOKEN)[A-Z_]*)\s*=\s*(\S+)/);++ if (m) problems.push(`${rel}:${i + 1} ${m[1]} has a value in .env.example; leave secrets empty`);++ });++ }++}++assert.deepEqual(problems, [], `public-surface violations:\n${problems.join('\n')}`);++++// The rule itself works (so a green run means something).++assert.ok(RULES[0].re.test('X-' + 'Internal-Key: k'));++assert.ok(RULES[1].re.test("path: '/" + "internal/identity/resolve'"));++assert.ok(RULES[2].re.test('http://127.0.0.1:' + '4300/api'));++assert.ok(!RULES[2].re.test('`http://127.0.0.1:${port}`'));++console.log(`public-surface: ok (${files.length} files)`);nnew file mode 100644@@@ -0,0 +1,58 @@++#!/usr/bin/env node++'use strict';++/**++ * Runs every example's smoke test (examples/<name>/test/smoke.test.js) and the repository checks++ * in test/*.test.js, each in its own process. No test needs the network or a running service:++ * the examples run against openvibe-sdk/testing's mock platform and small local mocks.++ *++ * npm test # everything++ * npm test -- chat oauth # only names containing one of the words++ */++const { spawn } = require('node:child_process');++const fs = require('node:fs');++const path = require('node:path');++++const ROOT = path.join(__dirname, '..');++const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));++const TIMEOUT_MS = 120000;++++const tests = [++ ...fs.readdirSync(path.join(ROOT, 'examples')).sort()++ .filter((d) => fs.existsSync(path.join(ROOT, 'examples', d, 'test', 'smoke.test.js')))++ .map((d) => ({ name: `examples/${d}`, file: path.join(ROOT, 'examples', d, 'test', 'smoke.test.js'), cwd: path.join(ROOT, 'examples', d) })),++ ...fs.readdirSync(__dirname).filter((f) => f.endsWith('.test.js')).sort()++ .map((f) => ({ name: `test/${f}`, file: path.join(__dirname, f), cwd: ROOT })),++].filter((t) => !filters.length || filters.some((w) => t.name.includes(w)));++++function runOne(t) {++ return new Promise((resolve) => {++ const started = Date.now();++ const child = spawn(process.execPath, [t.file], { cwd: t.cwd, env: { ...process.env, NODE_ENV: 'test' }, stdio: ['ignore', 'pipe', 'pipe'] });++ let output = '';++ child.stdout.on('data', (c) => { output += c; });++ child.stderr.on('data', (c) => { output += c; });++ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);++ child.on('close', (code, signal) => {++ clearTimeout(timer);++ resolve({ ...t, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });++ });++ });++}++++(async () => {++ if (!tests.length) { console.error('no tests matched'); process.exit(1); }++ console.log(`node ${process.version}`);++ const results = [];++ for (const t of tests) {++ const r = await runOne(t);++ results.push(r);++ console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.name.padEnd(34)} ${String(r.ms).padStart(6)}ms`);++ }++ const failed = results.filter((r) => !r.ok);++ for (const r of failed) {++ console.log(`\n-- ${r.name} (exit ${r.code}) --`);++ console.log(r.output.split('\n').slice(-60).join('\n'));++ }++ console.log(`\n${results.length - failed.length}/${results.length} passed`);++ process.exit(failed.length ? 1 : 0);++})();nnew file mode 100644@@@ -0,0 +1,61 @@++'use strict';++/**++ * Every example is copyable on its own: a README (with how to run it against the real platform),++ * a .env.example naming every variable its code reads, an MIT package.json whose dependencies are++ * pinned tags (no file: links), and a smoke test that the root runner picks up.++ */++const assert = require('node:assert/strict');++const fs = require('node:fs');++const path = require('node:path');++++const ROOT = path.join(__dirname, '..');++const EXPECTED = ['browser-app', 'chat-bot', 'event-subscriber', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'tool-job', 'webhook-consumer'];++const SDK = 'https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2';++++const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();++assert.deepEqual(dirs, EXPECTED, 'the nine charter examples');++++function sources(dir) {++ const out = [];++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {++ if (e.name === 'node_modules' || e.name === 'test') continue;++ const p = path.join(dir, e.name);++ if (e.isDirectory()) out.push(...sources(p));++ else if (/\.js$/.test(e.name)) out.push(p);++ }++ return out;++}++++for (const name of EXPECTED) {++ const dir = path.join(ROOT, 'examples', name);++ for (const f of ['README.md', '.env.example', 'package.json', 'test/smoke.test.js']) {++ assert.ok(fs.existsSync(path.join(dir, f)), `${name}/${f} exists`);++ }++ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));++ assert.equal(pkg.license, 'MIT', `${name}: MIT`);++ assert.equal(pkg.scripts.test, 'node test/smoke.test.js', `${name}: npm test runs the smoke test`);++ for (const [dep, spec] of Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })) {++ assert.ok(!/^(file|link):/.test(spec), `${name}: ${dep} is not a local link`);++ if (dep === 'openvibe-sdk') assert.equal(spec, SDK, `${name}: openvibe-sdk pinned to v0.2.2`);++ }++++ const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf8');++ assert.match(readme, /^## Run it against the real platform$/m, `${name}: README says how to run it for real`);++ assert.match(readme, /^## What it proves$/m, `${name}: README says what it proves`);++++ const envExample = fs.readFileSync(path.join(dir, '.env.example'), 'utf8');++ const declared = new Set([...envExample.matchAll(/^#?\s*(OV_[A-Z_]+)=/gm)].map((m) => m[1]));++ const used = new Set();++ for (const file of sources(dir)) {++ for (const m of fs.readFileSync(file, 'utf8').matchAll(/env\.(OV_[A-Z_]+)/g)) used.add(m[1]);++ }++ for (const v of used) assert.ok(declared.has(v), `${name}: ${v} is read by the code but missing from .env.example`);++}++++// The root runner and CI.++const ci = fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'ci.yml'), 'utf8');++assert.match(ci, /node-version: 22\.22\.1/);++assert.match(ci, /npm test/);++assert.equal(JSON.parse(fs.readFileSync(path.join(ROOT, 'STATUS.json'), 'utf8')).stage, 'alpha');++assert.match(fs.readFileSync(path.join(ROOT, 'LICENSE'), 'utf8'), /^MIT License/);++console.log('structure: ok');
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.22.1 # the Node version the platform's services run
+ - run: npm ci # one install for every example (npm workspaces)
+ # Every example's smoke test runs against openvibe-sdk/testing's mock platform and small local
+ # mocks (Chat WebSocket, Tools jobs). No test reaches the network or a real service.
+ - run: npm test
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+node_modules/
+.env
+.env.webhook
+data/
+out/
+.job-state.json
+*.db
+*.db-wal
+*.db-shm
@@ -1,661 +1,21 @@
@@ -1,661 +1,21 @@
- 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/>.
+MIT License
+
+Copyright (c) 2026 OpenVibers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
@@ -1,69 +1,201 @@
@@ -1,69 +1,201 @@
# OpenVibe.Examples
# OpenVibe.Examples
-> Executable public integration examples, tested in CI against the platform.
-
-**Status:** placeholder — planning only, no runnable code yet.
-**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.1 and §18.9.
-**License:** AGPL-3.0 (same as every OpenVibe service).
-
-## Purpose
-
-Replaces the effectively empty `powerchat-devapp-demo`. Every example runs against the platform integration environment using only public docs, the SDK and scoped credentials.
+> Executable public integration examples for the OpenVibe platform: the SDK, scoped credentials
+> and public APIs, nothing else.
+
+**Status:** alpha (roadmap Wave 20). Nine examples, each with a smoke test that CI runs on Node
+22.22.1 against `openvibe-sdk/testing`'s mock platform and two small local mocks. **None of them
+runs against the live platform in CI**, and several cannot run against it yet: see
+[What works against the real platform today](#what-works-against-the-real-platform-today).
+**Built on:** [openvibe-sdk v0.2.2](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.2.2), and
+openvibe-contracts v0.26.0 for the mod manifest.
+**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.1
+and §18.9; roadmap Wave 20, §30 (public SDK surface), ADR-014 (developer projects).
+**License:** MIT ([LICENSE](LICENSE)), so you can copy any example into your own project. The
+OpenVibe services themselves are AGPL-3.0.
+
+## The examples
+
+| Example | What it proves | Uses |
+|---|---|---|
+| [browser-app](examples/browser-app) | A plain page (no build step) signs in with PKCE and reads a public API from the browser; a public app needs no secret | `openvibe-sdk/auth/browser`, `/registry`, `/core` |
+| [node-server-app](examples/node-server-app) | Client-credentials app token per audience, registry discovery, contracts version check, trace propagation | `openvibe-sdk/auth`, `/registry`, `/core` |
+| [webhook-consumer](examples/webhook-consumer) | `X-OpenVibe-Signature` verified on the raw body; exactly-once handling with an inbox; secret rotation | `openvibe-sdk/events` (`parseDelivery`, `createInbox`, `subscriptions.create`) |
+| [event-subscriber](examples/event-subscriber) | Realtime subscription and pull with a durable cursor; resume after drops and restarts; gap handling | `openvibe-sdk/realtime`, `/events` |
+| [media-uploader](examples/media-uploader) | Upload into your project's Media namespace with an app token; content-addressed, safe retries | `openvibe-sdk/media` |
+| [chat-bot](examples/chat-bot) | A bot on Chat's WebSocket protocol that stays in its one configured room and always identifies as a bot | Chat `/ws/chat` protocol |
+| [tool-job](examples/tool-job) | An OpenVibe.Tools job submitted once (idempotency key), reattached after a restart, SSE resumed with `Last-Event-ID` | Tools `/api/v1/jobs`, `openvibe-sdk/core` |
+| [mod-manifest](examples/mod-manifest) | A `mods.mod-manifest@1` validated against the schema, the capability catalog and compatibility ranges | `openvibe-contracts`, `openvibe-sdk/registry` |
+| [oauth-app](examples/oauth-app) | Authorization code + PKCE with the exchange on the server; offline token verification; session hygiene | `openvibe-sdk/auth` |
+
+Each example folder has a README, a `.env.example`, its own `package.json` and a
+`test/smoke.test.js`. Configuration is environment variables only (`OV_CLIENT_ID`,
+`OV_CLIENT_SECRET`, `OV_NETWORK_URL`, …); secrets are never logged or sent to a browser.
+
+## Running the tests
+
+```bash
+fnm use 22.22.1 # or any Node 22
+npm ci # one install for all examples (npm workspaces)
+npm test # every smoke test + the repository checks
+npm test -- chat oauth # only some
+cd examples/tool-job && npm test
+```
+
+How the tests stay offline:
+
+- Network, Events and Media are `createMockPlatform()` from `openvibe-sdk/testing`: an in-process
+ fake at the real public origins, with real RS256 tokens and the same audience, capability and
+ namespace checks.
+- Chat and Tools have no SDK mock, so their examples carry a small local mock in their `test/`
+ folder (`mock-chat-server.js`, `mock-tools-server.js`) that plays the documented protocol.
+- Every smoke test replaces the global `fetch` with one that throws, so an accidental real network
+ call fails the test.
+
+Repository checks (`test/`):
+
+- `public-surface.test.js` fails on the loopback shared-key header, on the platform's internal
+ route prefix, on hard-coded loopback service ports outside the local mocks, and on any secret
+ value in a `.env.example`.
+- `structure.test.js` checks the nine examples are complete and copyable: README with "What it
+ proves" and "Run it against the real platform", a `.env.example` that names every variable the
+ code reads, MIT license, `openvibe-sdk` pinned to the v0.2.2 tag, no `file:` links.
+
+## End-to-end walkthrough
+
+From a new account to running the examples, with public surfaces only. OpenVibe.Codes (the
+developer portal) will be the UI for these steps; it has not launched, so this uses the
+developer-projects API on OpenVibe.Network directly. Every call is documented in Network's
+[developer projects](https://github.com/OpenVibers/OpenVibe.Network/blob/main/docs/developer-projects.md) doc.
+
+**1. Create an account** at <https://openvibe.network> (or `POST /api/auth/register`).
+
+**2. Get a user access token** for the projects API (it reads no cookies). This reads your
+password without echoing it or putting it on a command line:
+
+```bash
+NET=https://openvibe.network
+read -r -p 'username: ' OV_USER; read -r -s -p 'password: ' OV_PASS; echo; export OV_USER OV_PASS
+TOKEN=$(node -e 'process.stdout.write(JSON.stringify({ username: process.env.OV_USER, password: process.env.OV_PASS }))' \
+ | curl -s "$NET/api/auth/login" -H 'Content-Type: application/json' --data-binary @- \
+ | node -pe 'JSON.parse(require("fs").readFileSync(0)).token')
+unset OV_PASS
+auth=(-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json')
+```
+
+**3. See what can be granted** (only `public`, `active` capabilities; `first-party` and `internal` never are):
+
+```bash
+curl -s "$NET/api/v1/projects/catalog" "${auth[@]}"
+```
+
+**4. Create a project.** You are its owner. A new project is `sandbox` only.
+
+```bash
+PRJ=$(curl -s -X POST "$NET/api/v1/projects" "${auth[@]}" -d '{"name":"My first OpenVibe app"}' | node -pe 'JSON.parse(require("fs").readFileSync(0)).id')
+```
+
+**5. Create a sandbox app.** A confidential app gets a client secret, shown **once**; this writes
+it straight into an example's `.env` instead of the terminal:
+
+```bash
+cd examples/node-server-app
+curl -s -X POST "$NET/api/v1/projects/$PRJ/apps" "${auth[@]}" \
+ -d '{"name":"examples","environment":"sandbox","type":"confidential","redirect_uris":["http://localhost:3009/callback"]}' \
+ | node -e 'const a = JSON.parse(require("fs").readFileSync(0)); require("fs").writeFileSync(".env", `OV_CLIENT_ID=${a.client_id}\nOV_CLIENT_SECRET=${a.credential.client_secret}\n`, { mode: 0o600 }); console.log(a.client_id)'
+```
+
+For [browser-app](examples/browser-app) create a second app with `"type":"public"` and the
+redirect URI `http://localhost:3001/callback` (no secret).
+
+**6. Request grants.** One call per capability; the audience is the owning service
+(`openvibe.media` for `media.*`). As the project owner, a request inside the project's allowance is
+approved at once; a developer's request waits for an owner or admin
+(`POST …/grants/<capability>/approve`).
+
+```bash
+APP=app_... # the client id printed above
+for c in media.object.upload media.object.read tools.job.create tools.job.read; do
+ curl -s -X POST "$NET/api/v1/projects/$PRJ/apps/$APP/grants" "${auth[@]}" -d "{\"capability\":\"$c\"}"; echo
+done
+```
+
+**7. Run the examples.** Start with [node-server-app](examples/node-server-app): its `/status`
+shows the services the registry knows and, per audience, whether Network will give your app a
+token and which capabilities it carries. Then follow each example's "Run it against the real
+platform" section.
+
+Rotate a secret with `POST …/apps/$APP/credentials/rotate`, revoke one with
+`POST …/credentials/<id>/revoke`, revoke the app with `DELETE …/apps/$APP`. Issued tokens end
+within their 5-minute lifetime.
+
+## What works against the real platform today
+
+These are the platform-side conditions as of this release, read from the services' code and docs.
+Nothing here was run against production.
+
+| Example | Today |
+|---|---|
+| mod-manifest | Works: validation is offline; `--registry` reads the public registry. Publishing a mod is not public yet. |
+| node-server-app | Discovery and registry work for anyone. App tokens: see "Tokens" below. |
+| chat-bot | Should work on openvibe.live chat with a Live API token (`hbt_…`, scope `chat`) from a dedicated bot account. Not a developer-app credential: Chat has no app principal, and `chat.message.send` is `first-party`. |
+| tool-job | Tools accepts app tokens on `/api/v1/jobs`. Needs a token for `openvibe.tools` (see "Tokens"). |
+| media-uploader | Needs a token for `openvibe.media` (see "Tokens") **and** a Media tenant named after your project id, created by the Media operators. |
+| oauth-app, browser-app | Sign-in needs a token for the requested audience (see "Tokens"); production apps need an https redirect URI. The browser cannot read the registry cross-origin yet (Network's CORS allow-list runs first), so browser-app falls back to its own server and says so. |
+| event-subscriber | Realtime works anonymously for `public` events. Pull cannot work: `events.event.read` is `internal` and the pull API is not on Events' public host. |
+| webhook-consumer | Cannot be demonstrated: `events.subscription.manage` is `internal`, the subscription API is not on Events' public host, and deliveries only go to allow-listed hosts. The consumer itself is ready. |
+
+**Tokens.** Two defaults on Network block every app token until staff change them:
+
+- `DEV_SANDBOX_AUDIENCES` is empty, so a **sandbox** app gets `400 invalid_target` for every
+ audience, and receivers built on openvibe-contracts ≥ 0.26 also refuse `env: sandbox` tokens
+ unless they opt in. A **production** app avoids this, but only after staff switch the project to
+ `sandbox+production`.
+- `DEV_DEFAULT_ALLOWANCE` is empty, so a grant request stays unapproved (`403 grant.beyond_allowance`
+ on approval) until staff put the capability in your project's allowance.
+
+Capabilities a developer app can hold today (`public` + `active` in openvibe-contracts v0.26.0):
+`media.object.upload`, `media.object.read`, `tools.job.create`, `tools.job.read`,
+`tools.job.cancel`, `games.mod.read`, `games.world.announce`, `games.prop.place`. No `events.*`,
+`chat.*` or identity capability is grantable.
+
+So the Wave 20 exit criterion ("a new external developer goes from account creation to a working
+Media, event and capability integration using only public documentation, the SDK and scoped
+credentials") is **not met on the live platform yet**: Media needs staff actions (sandbox
+audience or production policy, allowance, Media tenant), and the event integration has no public
+path except anonymous realtime.
+
+## Layout
+
+```
+examples/<name>/ one example: README.md, .env.example, package.json, code, test/smoke.test.js
+test/run.js runs every smoke test and the repository checks (npm test)
+test/public-surface.test.js, test/structure.test.js
+.github/workflows/ci.yml Node 22.22.1: npm ci && npm test
+```
## Owns
## Owns
-- vanilla browser app, Node server app, webhook consumer, event subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example
+- the nine charter examples: vanilla browser app, Node server app, webhook consumer, event
+ subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example
## Does not own
## Does not own
-- any platform service
-
-## Planned surfaces
-
-- one directory per example with a README and a smoke test
-
-## Data (authority tables / families)
-
-- none
-
-## Capabilities and events
-
-- consumes public capabilities only
-
-Events: n/a
+- any platform service, the SDK (OpenVibe.SDK) or the contracts (OpenVibe.Contracts)
## Depends on
## Depends on
-- OpenVibe.SDK
-- OpenVibe.Contracts
-- the platform integration environment (Network, Events, Media, Chat, Community, Live adapters)
-
-## Acceptance (must be true before "done")
-
-- CI runs every example against the integration environment
-- no example needs a loopback-only internal key or first-party database access
-
-## Bootstrap / extraction source
-
-New; supersedes powerchat-devapp-demo.
-
-## Launch rule
-
-This repository does not make the product real, and the domain keeps its placeholder page on
-[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the
-following exist here (plan §12.12):
+- OpenVibe.SDK (v0.2.2), OpenVibe.Contracts (v0.26.0, mod manifest only)
+- at run time against the real platform: Network (tokens, registry, developer projects), Events
+ (realtime), Media, Tools, Chat (on openvibe.live)
-1. an owning runtime with health/readiness endpoints and observability;
-2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);
-3. server-rendered or static public routes that are useful without JavaScript;
-4. real persistence and end-to-end workflows;
-5. capability and event registration against `OpenVibe.Contracts`;
-6. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;
-7. acceptance tests proving the advertised functionality.
+## Acceptance
-The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and
-registers maturity in the ecosystem registry atomically. A placeholder is never counted as an
-implemented service.
+- CI runs every example: **yes**, against the SDK's mock platform and local mocks (no network).
+ Running them in CI against an integration environment needs sandbox-enabled audiences and a
+ CI project with grants; that environment does not exist yet.
+- No example needs a loopback-only internal key or first-party database access: **yes**, enforced
+ by `test/public-surface.test.js`.
---
---
@@ -1,7 +1,12 @@
@@ -1,7 +1,12 @@
{
{
"repository": "OpenVibers/OpenVibe.Examples",
"repository": "OpenVibers/OpenVibe.Examples",
- "stage": "placeholder",
- "code": false,
- "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "updated": "2026-09-21"
+ "stage": "alpha",
+ "code": true,
+ "wave": "W20",
+ "version": "0.1.0",
+ "sdk": "openvibe-sdk v0.2.2",
+ "contracts": "openvibe-contracts v0.26.0 (mod-manifest example)",
+ "ci": "every example's smoke test runs on Node 22.22.1 against openvibe-sdk/testing's mock platform and local mocks; none runs against the live platform",
+ "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.1, §18.9; roadmap Wave 20, §30, ADR-014",
+ "updated": "2026-09-22"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+# A PUBLIC developer app (type "public": no client secret) in your OpenVibe project.
+# Register the redirect URI below on the app exactly as written.
+OV_NETWORK_URL=https://openvibe.network
+OV_CLIENT_ID=
+# Sign-in asks for capabilities of ONE audience; the app needs an approved grant for each.
+OV_AUDIENCE=openvibe.media
+OV_SCOPE=media.object.read
+OV_REDIRECT_URI=http://localhost:3001/callback
+# Defaults to the redirect URI's port.
+OV_PORT=3001
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+# Vanilla browser app
+
+A plain HTML page, no framework and no build step, that signs in with OpenVibe using PKCE
+(`openvibe-sdk/auth/browser`) and reads a public API (the ecosystem registry) straight from the browser.
+
+```bash
+node --env-file=.env server.js # open http://localhost:3001
+```
+
+## What it proves
+
+- **PKCE in the browser.** The page calls `startAuthorization()`; the verifier and `state` live in
+ `sessionStorage` until Network redirects back to `/callback`, where `readCallback()` checks
+ `state` and the address bar is cleaned. The verifier is used once.
+- **A public app has no secret.** The app is registered as `public`. Its tiny server exchanges
+ code + verifier at Network's token endpoint (no secret; `audience` is sent) and keeps the
+ resulting token in an HttpOnly session. It holds no secrets at all. The exchange endpoint only
+ accepts same-origin JSON requests (`Origin` check, `415` otherwise), so another site cannot drive it.
+- **A public API from the page.** `openvibe-sdk/registry` lists the network's services with no
+ token and no cookie. The DOM is built with `textContent`, never `innerHTML`, under a strict CSP.
+- **The SDK in a page without a bundler.** `openvibe-sdk` is CommonJS, so `lib/sdk-bundle.js`
+ serves its browser-safe modules (`core`, `auth/browser`, `registry`) as one script that sets
+ `window.OpenVibeSDK`. It refuses to include anything that is not a relative SDK module, and the
+ test checks the served script contains no server-only code. With a bundler, import
+ `openvibe-sdk/auth/browser` directly instead.
+
+## Files
+
+| File | What |
+|---|---|
+| `public/index.html`, `public/app.js` | the page |
+| `server.js` | static files, `/sdk/openvibe-sdk.js`, `/config.json`, `/auth/exchange`, `/api/session`, `/api/registry/services`, `/auth/logout` |
+| `lib/sdk-bundle.js` | the serve-time wrapper for the SDK's browser modules |
+| `test/smoke.test.js` | runs `public/app.js` and the served bundle in a small fake browser (vm) against the server and the SDK mock |
+
+## Run the smoke test
+
+```bash
+npm test
+```
+
+There is no real browser in CI: the test gives the page's own script a minimal DOM,
+`sessionStorage`, `location`, a cookie jar and `fetch`, clicks "Sign in", follows the redirect with
+a code from `platform.authorize()` (which stands in for Network's consent page and verifies PKCE),
+and checks the signed-in state, a forged callback, the registry read and its fallback.
+
+## Run it against the real platform
+
+1. Create a project and a **public** app with the redirect URI `http://localhost:3001/callback`
+ ([walkthrough](../../README.md#end-to-end-walkthrough)); request `media.object.read` (or any
+ grant for the audience you put in `OV_AUDIENCE`).
+2. `cp .env.example .env`, set `OV_CLIENT_ID`.
+3. `node --env-file=.env server.js`, open `http://localhost:3001`, sign in as a project member.
+
+What does not work yet on the real platform:
+
+- **The registry refuses third-party browser origins.** Network answers the registry with
+ `Access-Control-Allow-Origin: *`, but its site-wide CORS allow-list rejects unknown origins
+ first, and SDK calls carry `traceparent` / `X-OpenVibe-Request-Id` headers, which need a
+ preflight. The page therefore falls back to reading the same public registry through its own
+ server (`/api/registry/services`) and says so on the page.
+- **Sign-in** needs Network to mint a token for `OV_AUDIENCE`: a sandbox app gets `invalid_target`
+ until staff list that audience in `DEV_SANDBOX_AUDIENCES`; a production app needs an https
+ redirect URI. Sign-in always asks for a capability: there is no identity-only sign-in for
+ apps, and an app learns only the person's `usr_…` id (profile lookups are `first-party`).
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+/**
+ * Serve openvibe-sdk's browser-safe modules to a page with no build step.
+ *
+ * openvibe-sdk 0.2.2 is CommonJS, and its ESM entry points import CommonJS files, so a browser
+ * cannot load it directly without a bundler. This file wraps the modules a sign-in page needs
+ * (core, auth/browser, registry) and everything they require into one script that sets
+ * `window.OpenVibeSDK = { core, auth, registry }`. It refuses anything that is not a relative
+ * require inside the SDK (no `node:` modules, no packages), so server-only code can never slip in.
+ *
+ * If you use a bundler (esbuild, Vite, webpack), import 'openvibe-sdk/auth/browser' etc. instead.
+ */
+const fs = require('node:fs');
+const path = require('node:path');
+const crypto = require('node:crypto');
+
+const ENTRIES = { core: 'src/core/index.js', auth: 'src/auth/browser.js', registry: 'src/registry.js' };
+const REQUIRE_RE = /require\(\s*'([^']+)'\s*\)/g;
+
+function buildSdkBundle(root = path.dirname(require.resolve('openvibe-sdk/package.json'))) {
+ const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
+ const modules = new Map();
+ const visit = (id) => {
+ if (modules.has(id)) return;
+ const source = fs.readFileSync(path.join(root, id), 'utf8');
+ modules.set(id, null);
+ const deps = {};
+ for (const [, spec] of source.matchAll(REQUIRE_RE)) {
+ if (!spec.startsWith('.')) throw new Error(`${id} requires "${spec}": not browser-safe, refusing to bundle`);
+ let dep = path.posix.normalize(path.posix.join(path.posix.dirname(id), spec));
+ if (!dep.endsWith('.js')) dep += '.js';
+ deps[spec] = dep;
+ visit(dep);
+ }
+ if (/process\.env|client_secret/.test(source)) throw new Error(`${id} references server-only material`);
+ modules.set(id, { source, deps });
+ };
+ for (const id of Object.values(ENTRIES)) visit(id);
+
+ const defs = [...modules].map(([id, { source, deps }]) => ` ${JSON.stringify(id)}: [${JSON.stringify(deps)}, function (module, exports, require) {\n${source}\n}]`).join(',\n');
+ const code = `/* openvibe-sdk ${version}: core, auth/browser, registry (MIT). Generated at serve time by examples/browser-app/lib/sdk-bundle.js. */
+(function (global) {
+ 'use strict';
+ var defs = {
+${defs}
+ };
+ var cache = {};
+ function load(id) {
+ if (cache[id]) return cache[id].exports;
+ var def = defs[id];
+ if (!def) throw new Error('openvibe-sdk bundle: no module ' + id);
+ var module = cache[id] = { exports: {} };
+ def[1](module, module.exports, function (spec) { return load(def[0][spec]); });
+ return module.exports;
+ }
+ global.OpenVibeSDK = { version: ${JSON.stringify(version)}, ${Object.entries(ENTRIES).map(([k, id]) => `${k}: load(${JSON.stringify(id)})`).join(', ')} };
+})(typeof window !== 'undefined' ? window : globalThis);
+`;
+ return { code, version, etag: `"${crypto.createHash('sha256').update(code).digest('base64url').slice(0, 27)}"`, modules: [...modules.keys()] };
+}
+
+module.exports = { buildSdkBundle, ENTRIES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+{
+ "name": "openvibe-example-browser-app",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Vanilla browser app: sign in with OpenVibe.Network (authorization code + PKCE) and call a public API from the browser.",
+ "license": "MIT",
+ "main": "server.js",
+ "scripts": {
+ "start": "node --env-file-if-exists=.env server.js",
+ "test": "node test/smoke.test.js"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
+/* OpenVibe browser app example: sign in with PKCE, then read the public registry.
+ * Plain browser JavaScript, no framework and no build step. window.OpenVibeSDK comes from
+ * /sdk/openvibe-sdk.js (openvibe-sdk's core, auth/browser and registry modules). DOM nodes are
+ * built with textContent, never innerHTML. */
+(function () {
+ 'use strict';
+ var sdk = window.OpenVibeSDK;
+ var PKCE_KEY = 'ov_example_pkce';
+ var $ = function (id) { return document.getElementById(id); };
+
+ function showError(err) {
+ var el = $('auth-error');
+ el.textContent = 'Sign-in failed: ' + ((err && (err.code || err.message)) || 'unknown error');
+ el.hidden = false;
+ }
+
+ function getJson(url, init) {
+ return fetch(url, Object.assign({ credentials: 'same-origin', headers: { Accept: 'application/json' } }, init || {}))
+ .then(function (res) { return res.json().then(function (body) { return { status: res.status, body: body }; }); });
+ }
+
+ /** Back from Network on /callback: check state, hand code + verifier to this app's server. */
+ function finishSignIn() {
+ var href = location.href;
+ var saved = null;
+ try { saved = JSON.parse(sessionStorage.getItem(PKCE_KEY) || 'null'); } catch (e) { saved = null; }
+ sessionStorage.removeItem(PKCE_KEY);
+ history.replaceState(null, '', '/');
+ var callback;
+ try {
+ if (!saved) throw { code: 'oauth.no_pending_sign_in' };
+ callback = sdk.auth.readCallback(href, { expectedState: saved.state });
+ } catch (err) { showError(err); return Promise.resolve(); }
+ return getJson('/auth/exchange', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify({ code: callback.code, codeVerifier: saved.codeVerifier }),
+ }).then(function (r) { if (r.status !== 200) showError({ code: r.body.error }); });
+ }
+
+ function startSignIn(config) {
+ return sdk.auth.startAuthorization({ network: config.network, clientId: config.clientId, redirectUri: config.redirectUri, scope: config.scope })
+ .then(function (a) {
+ sessionStorage.setItem(PKCE_KEY, JSON.stringify({ state: a.state, codeVerifier: a.codeVerifier }));
+ location.assign(a.url);
+ });
+ }
+
+ function showSession() {
+ return getJson('/api/session').then(function (r) {
+ var signedIn = r.status === 200;
+ $('who').textContent = signedIn
+ ? 'Signed in with OpenVibe as ' + r.body.subject + '. This app may: ' + (r.body.capabilities.join(', ') || 'nothing') + '.'
+ : 'Not signed in.';
+ $('sign-in').hidden = signedIn;
+ $('sign-out').hidden = !signedIn;
+ });
+ }
+
+ function renderServices(services, via) {
+ var list = $('services');
+ list.textContent = '';
+ services.forEach(function (s) {
+ var li = document.createElement('li');
+ var name = document.createElement('code');
+ name.textContent = s.id;
+ li.appendChild(name);
+ li.appendChild(document.createTextNode(' ' + s.status + (s.publicOrigin ? ' - ' + s.publicOrigin : '')));
+ list.appendChild(li);
+ });
+ $('registry-status').textContent = services.length + ' services, ' + via + '.';
+ }
+
+ /** A public API straight from the browser: no token, no cookie. */
+ function loadRegistry(config) {
+ var client = sdk.core.createClient({ network: config.network, retries: 1 });
+ return sdk.registry.createRegistryClient(client).services()
+ .then(function (services) { renderServices(services, 'read by this page from ' + config.network); })
+ .catch(function () {
+ // The browser refused the cross-origin call (CORS). Read it through this app's server.
+ return getJson('/api/registry/services').then(function (r) {
+ if (r.status !== 200) throw new Error(r.body.error);
+ renderServices(r.body.services, 'read through this app\'s server (the registry does not allow this origin from a browser yet)');
+ });
+ })
+ .catch(function (err) { $('registry-status').textContent = 'Could not read the registry: ' + (err.code || err.message); });
+ }
+
+ getJson('/config.json').then(function (r) {
+ var config = r.body;
+ $('sign-in').addEventListener('click', function () { startSignIn(config).catch(showError); });
+ $('sign-out').addEventListener('click', function () {
+ getJson('/auth/logout', { method: 'POST' }).then(showSession);
+ });
+ var done = location.pathname === new URL(config.redirectUri).pathname ? finishSignIn() : Promise.resolve();
+ return done.then(showSession).then(function () { return loadRegistry(config); });
+ });
+}());
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>OpenVibe browser app example</title>
+ <style>
+ body { font: 16px/1.5 system-ui, sans-serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem; color: #1b1b1f; background: #fff; }
+ @media (prefers-color-scheme: dark) { body { color: #e7e7ea; background: #131316; } }
+ code { font-size: 0.9em; }
+ .error { color: #c62828; }
+ ul { padding-left: 1.2rem; }
+ </style>
+</head>
+<body>
+ <h1>OpenVibe browser app example</h1>
+ <p>Sign in with OpenVibe (authorization code + PKCE), then read the public ecosystem registry from this page.</p>
+
+ <section>
+ <h2>Account</h2>
+ <p id="who">Checking your session...</p>
+ <button id="sign-in" hidden>Sign in with OpenVibe</button>
+ <button id="sign-out" hidden>Sign out</button>
+ <p id="auth-error" class="error" hidden></p>
+ </section>
+
+ <section>
+ <h2>Services on the network</h2>
+ <p id="registry-status">Loading the registry...</p>
+ <ul id="services"></ul>
+ </section>
+
+ <noscript><p>This example needs JavaScript: it shows how a browser signs in with the SDK.</p></noscript>
+ <script src="/sdk/openvibe-sdk.js"></script>
+ <script src="/app.js"></script>
+</body>
+</html>
new file mode 100644
new file mode 100644
@@ -0,0 +1,167 @@
@@ -0,0 +1,167 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Vanilla browser app: a static page that signs in with OpenVibe.Network using PKCE
+ * (openvibe-sdk/auth/browser in the browser) and calls a public API (the ecosystem registry)
+ * straight from the browser.
+ *
+ * node --env-file=.env server.js # http://localhost:3001
+ *
+ * The app is a PUBLIC developer app: it has no client secret. The browser makes the PKCE pair and
+ * keeps the verifier in sessionStorage; after the redirect it hands code + verifier to this tiny
+ * server, which exchanges them at Network's token endpoint (Network does not open its token
+ * endpoint to third-party origins, and the resulting token is kept out of page scripts in an
+ * HttpOnly session). The server has no secrets at all.
+ *
+ * GET /, /callback the page (public/index.html)
+ * GET /app.js the page's script
+ * GET /sdk/openvibe-sdk.js openvibe-sdk's browser-safe modules as one script (lib/sdk-bundle.js)
+ * GET /config.json public settings: network, client id, redirect URI, scope
+ * POST /auth/exchange { code, codeVerifier } -> session cookie, { subject }
+ * GET /api/session who is signed in
+ * GET /api/registry/services the same public registry read, server side: the page's fallback
+ * while Network does not allow third-party origins on the registry
+ * POST /auth/logout
+ */
+const crypto = require('node:crypto');
+const fs = require('node:fs');
+const http = require('node:http');
+const path = require('node:path');
+const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
+const { verifyUserToken } = require('openvibe-sdk/auth');
+const { createRegistryClient } = require('openvibe-sdk/registry');
+const { buildSdkBundle } = require('./lib/sdk-bundle');
+
+const SESSION_COOKIE = 'ov_browser_example_sid';
+const PUBLIC = path.join(__dirname, 'public');
+
+function loadConfig(env = process.env) {
+ const missing = ['OV_CLIENT_ID', 'OV_AUDIENCE', 'OV_SCOPE'].filter((k) => !env[k]);
+ if (missing.length) throw Object.assign(new Error(`missing environment variables: ${missing.join(', ')} (see .env.example)`), { code: 'config.missing' });
+ const redirectUri = env.OV_REDIRECT_URI || 'http://localhost:3001/callback';
+ return {
+ network: env.OV_NETWORK_URL || 'https://openvibe.network',
+ clientId: env.OV_CLIENT_ID,
+ audience: env.OV_AUDIENCE,
+ scope: env.OV_SCOPE.split(/[\s,]+/).filter(Boolean),
+ redirectUri,
+ origin: new URL(redirectUri).origin,
+ port: Number(env.OV_PORT || new URL(redirectUri).port || 3001),
+ };
+}
+
+/** Network's token endpoint, authorization_code for a public app (no secret; `audience` is required). */
+async function exchangePublicCode(client, { clientId, code, codeVerifier, redirectUri, audience, scope }) {
+ return client.json({
+ service: 'network', path: '/oauth/token', method: 'POST', auth: false, retries: 0, idempotencyKey: false,
+ urlencoded: { grant_type: 'authorization_code', client_id: clientId, code, code_verifier: codeVerifier, redirect_uri: redirectUri, audience, scope: scope.join(' ') },
+ });
+}
+
+function createApp(config, { fetch, log = console } = {}) {
+ const client = createClient({ network: config.network, fetch });
+ const bundle = buildSdkBundle();
+ const sessions = new Map(); // sid -> { subject, capabilities, expiresAt }
+ const secure = config.redirectUri.startsWith('https://');
+ const cookie = (sid, maxAge) => `${SESSION_COOKIE}=${sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure ? '; Secure' : ''}`;
+ const sidOf = (req) => {
+ const m = String(req.headers.cookie || '').match(new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([A-Za-z0-9_-]+)`));
+ return m ? m[1] : null;
+ };
+ const current = (req) => {
+ const s = sessions.get(sidOf(req));
+ return s && s.expiresAt > Date.now() ? s : null;
+ };
+ const publicConfig = JSON.stringify({ network: config.network, clientId: config.clientId, redirectUri: config.redirectUri, scope: config.scope });
+
+ function json(res, status, body, headers = {}) {
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers });
+ res.end(JSON.stringify(body));
+ }
+ function file(res, name, type) {
+ res.writeHead(200, {
+ 'Content-Type': type, 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff',
+ 'Content-Security-Policy': `default-src 'self'; connect-src 'self' ${config.network}; img-src 'self' data:; style-src 'self' 'unsafe-inline'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'`,
+ });
+ res.end(fs.readFileSync(path.join(PUBLIC, name)));
+ }
+ function readJson(req, limit = 16 * 1024) {
+ return new Promise((resolve, reject) => {
+ let body = '';
+ req.on('data', (c) => { body += c; if (body.length > limit) reject(Object.assign(new Error('too large'), { status: 413 })); });
+ req.on('end', () => { try { resolve(JSON.parse(body || '{}')); } catch { reject(Object.assign(new Error('bad json'), { status: 400 })); } });
+ });
+ }
+
+ async function exchange(req, res) {
+ // Only this page may call this: same Origin, JSON body (a cross-site form cannot send either).
+ if (req.headers.origin !== config.origin) return json(res, 403, { error: 'bad_origin' });
+ if (!/^application\/json\b/.test(req.headers['content-type'] || '')) return json(res, 415, { error: 'json_required' });
+ const { code, codeVerifier } = await readJson(req);
+ if (typeof code !== 'string' || !code || typeof codeVerifier !== 'string' || !/^[A-Za-z0-9._~-]{43,128}$/.test(codeVerifier)) {
+ return json(res, 400, { error: 'bad_request' });
+ }
+ try {
+ const tokens = await exchangePublicCode(client, { ...config, code, codeVerifier });
+ const d = await client.discover();
+ const claims = await verifyUserToken(tokens.access_token, {
+ jwks: d.jwksUri || `${config.network}/api/.well-known/jwks`, issuer: d.issuer || undefined, audience: config.audience, allowServiceTokens: true, fetch,
+ });
+ // Developer-app tokens name the person in on_behalf_of and must be for this app.
+ const subject = claims.actor_type === 'app' ? (claims.sub === `app:${config.clientId}` ? claims.on_behalf_of : null) : claims.subject_id;
+ if (!subject) return json(res, 400, { error: 'no_subject' });
+ for (const [k, v] of sessions) if (v.expiresAt <= Date.now()) sessions.delete(k); // in-memory store: clean up
+ const sid = crypto.randomBytes(32).toString('base64url');
+ sessions.set(sid, { subject, capabilities: claims.cap || [], expiresAt: claims.exp * 1000 });
+ log.log(`[browser-app] signed in ${subject}`);
+ return json(res, 200, { subject, capabilities: claims.cap || [] }, { 'Set-Cookie': cookie(sid, Math.max(60, claims.exp - Math.floor(Date.now() / 1000))) });
+ } catch (err) {
+ log.error(`[browser-app] exchange failed: ${isOpenVibeError(err) ? `${err.code} request=${err.requestId}` : err.message}`);
+ return json(res, 400, { error: isOpenVibeError(err) ? err.code : 'exchange_failed' });
+ }
+ }
+
+ const server = http.createServer(async (req, res) => {
+ const url = new URL(req.url, 'http://app.invalid');
+ try {
+ if (req.method === 'GET' && (url.pathname === '/' || url.pathname === new URL(config.redirectUri).pathname)) return file(res, 'index.html', 'text/html; charset=utf-8');
+ if (req.method === 'GET' && url.pathname === '/app.js') return file(res, 'app.js', 'text/javascript; charset=utf-8');
+ if (req.method === 'GET' && url.pathname === '/sdk/openvibe-sdk.js') {
+ if (req.headers['if-none-match'] === bundle.etag) { res.writeHead(304, { ETag: bundle.etag }); return res.end(); }
+ res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-cache', ETag: bundle.etag });
+ return res.end(bundle.code);
+ }
+ if (req.method === 'GET' && url.pathname === '/config.json') {
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' });
+ return res.end(publicConfig);
+ }
+ if (req.method === 'POST' && url.pathname === '/auth/exchange') return await exchange(req, res);
+ if (req.method === 'GET' && url.pathname === '/api/session') {
+ const s = current(req);
+ return s ? json(res, 200, { subject: s.subject, capabilities: s.capabilities, expires_at: new Date(s.expiresAt).toISOString() }) : json(res, 401, { error: 'not_signed_in' });
+ }
+ if (req.method === 'GET' && url.pathname === '/api/registry/services') {
+ return json(res, 200, { services: await createRegistryClient(client).services() }, { 'Cache-Control': 'public, max-age=30' });
+ }
+ if (req.method === 'POST' && url.pathname === '/auth/logout') {
+ if (req.headers.origin !== config.origin) return json(res, 403, { error: 'bad_origin' });
+ sessions.delete(sidOf(req));
+ return json(res, 200, { ok: true }, { 'Set-Cookie': cookie('', 0) });
+ }
+ return json(res, 404, { error: 'not_found' });
+ } catch (err) {
+ if (!res.headersSent) json(res, err.status || 500, { error: err.status ? err.message : 'error' });
+ return undefined;
+ }
+ });
+ return { server, sessions, bundle };
+}
+
+if (require.main === module) {
+ let config;
+ try { config = loadConfig(); } catch (err) { console.error(err.message); process.exit(2); }
+ const { server } = createApp(config);
+ server.listen(config.port, () => console.log(`browser-app: ${config.origin} (redirect URI ${config.redirectUri})`));
+}
+
+module.exports = { loadConfig, createApp, exchangePublicCode };
new file mode 100644
new file mode 100644
@@ -0,0 +1,174 @@
@@ -0,0 +1,174 @@
+'use strict';
+/**
+ * Smoke test: the page's own script (public/app.js) and the served SDK bundle run in a small fake
+ * browser (a vm context with a minimal DOM, sessionStorage, location, a cookie jar and fetch),
+ * against this app's server and the SDK's mock platform. Covers: sign-in button -> authorize URL
+ * with PKCE, the callback -> exchange -> signed-in session, the registry read from the page, and
+ * the fallback through the app's server when the browser refuses the cross-origin call.
+ */
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+const { createMockPlatform } = require('openvibe-sdk/testing');
+const { createApp, loadConfig } = require('../server');
+
+const realFetch = globalThis.fetch;
+globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
+
+const APP = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TA';
+
+async function waitFor(check, what, ms = 3000) {
+ const until = Date.now() + ms;
+ while (Date.now() < until) {
+ if (check()) return;
+ await new Promise((r) => setTimeout(r, 10));
+ }
+ throw new Error(`timed out waiting for ${what}`);
+}
+
+/** A tiny browser: enough DOM for app.js, and fetch that talks to the app server or the mock. */
+function createBrowser({ base, origin, platform, sessionStorage, jar, crossOrigin = 'allow' }) {
+ const elements = new Map();
+ const el = (id) => {
+ if (!elements.has(id)) {
+ const node = {
+ id, hidden: false, children: [], listeners: {}, _text: '',
+ get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); },
+ set textContent(v) { this._text = String(v); this.children = []; },
+ appendChild(c) { this.children.push(c); return c; },
+ addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); },
+ click() { for (const fn of this.listeners.click || []) fn({}); },
+ };
+ elements.set(id, node);
+ }
+ return elements.get(id);
+ };
+ const document = {
+ getElementById: el,
+ createElement: () => el(`anon-${elements.size}`),
+ createTextNode: (t) => ({ textContent: String(t) }),
+ };
+ const location = {
+ href: '', pathname: '/', assigned: null,
+ assign(u) { this.assigned = u; },
+ };
+ const setUrl = (u) => { const x = new URL(u, origin); location.href = x.href; location.pathname = x.pathname; };
+ async function browserFetch(input, init = {}) {
+ const url = new URL(String(input), location.href);
+ if (url.origin === origin) {
+ const headers = new Headers(init.headers || {});
+ if (jar.cookie) headers.set('cookie', jar.cookie);
+ if ((init.method || 'GET') !== 'GET') headers.set('origin', origin); // browsers send Origin on POST
+ const res = await realFetch(`${base}${url.pathname}${url.search}`, { ...init, headers });
+ const set = res.headers.get('set-cookie');
+ if (set) jar.cookie = set.split(';')[0].endsWith('=') ? '' : set.split(';')[0];
+ return res;
+ }
+ if (crossOrigin === 'refuse') throw new TypeError('Failed to fetch'); // what a CORS refusal looks like
+ return platform.fetch(input, init);
+ }
+ const context = vm.createContext({
+ document, location, sessionStorage, fetch: browserFetch,
+ history: { replaceState: (_s, _t, u) => setUrl(u) },
+ console, setTimeout, clearTimeout, URL, URLSearchParams, Headers, Request, Response, AbortController,
+ TextEncoder, TextDecoder, btoa, atob, crypto: globalThis.crypto,
+ });
+ context.window = context;
+ return { context, el, location, setUrl };
+}
+
+(async () => {
+ const platform = createMockPlatform({ users: [{ username: 'ana' }] });
+ const ana = [...platform.state.users.values()][0];
+ const env = { OV_CLIENT_ID: APP, OV_AUDIENCE: 'openvibe.media', OV_SCOPE: 'media.object.read' };
+ const config = loadConfig(env);
+ platform.addClient(APP, { secret: null, redirectUris: [config.redirectUri] }); // a public app: no secret
+ const logs = [];
+ const app = createApp(config, { fetch: platform.fetch, log: { log: (m) => logs.push(m), error: (m) => logs.push(m) } });
+ await new Promise((r) => app.server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${app.server.address().port}`;
+
+ // The served bundle is browser-safe: only SDK modules, nothing server-side.
+ const bundleRes = await realFetch(`${base}/sdk/openvibe-sdk.js`);
+ assert.equal(bundleRes.status, 200);
+ const bundle = await bundleRes.text();
+ assert.ok(!/client_secret|process\.env|require\('node:|require\('crypto'\)/.test(bundle));
+ assert.deepEqual(app.bundle.modules.sort(), ['src/auth/browser.js', 'src/core/client.js', 'src/core/errors.js', 'src/core/ids.js', 'src/core/index.js', 'src/core/paginate.js', 'src/core/semver.js', 'src/core/trace.js', 'src/registry.js']);
+ assert.equal((await realFetch(`${base}/sdk/openvibe-sdk.js`, { headers: { 'if-none-match': bundleRes.headers.get('etag') } })).status, 304);
+ const page = await (await realFetch(`${base}/`)).text();
+ assert.match(page, /<script src="\/sdk\/openvibe-sdk\.js"><\/script>/);
+ const publicConfig = await (await realFetch(`${base}/config.json`)).json();
+ assert.deepEqual(Object.keys(publicConfig).sort(), ['clientId', 'network', 'redirectUri', 'scope']);
+ const appJs = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8');
+
+ const storage = new Map();
+ const sessionStorage = { getItem: (k) => (storage.has(k) ? storage.get(k) : null), setItem: (k, v) => storage.set(k, String(v)), removeItem: (k) => storage.delete(k) };
+ const jar = { cookie: '' };
+ const load = (url, opts = {}) => {
+ const b = createBrowser({ base, origin: config.origin, platform, sessionStorage, jar, ...opts });
+ b.setUrl(url);
+ vm.runInContext(bundle, b.context);
+ vm.runInContext(appJs, b.context);
+ return b;
+ };
+
+ // 1. First visit: not signed in; the registry is read by the page itself (no token, no cookie).
+ let b = load('/');
+ await waitFor(() => /services, read by this page/.test(b.el('registry-status').textContent), 'the registry read');
+ assert.equal(b.el('who').textContent, 'Not signed in.');
+ assert.equal(b.el('sign-in').hidden, false);
+ assert.ok(b.el('services').children.some((li) => li.textContent.startsWith('media alpha')));
+ const registryCall = platform.stats.requests.find((r) => r.url.includes('/api/v1/registry/services'));
+ assert.ok(!registryCall.headers.authorization);
+
+ // 2. Sign in: the page makes the PKCE pair and goes to Network's authorize URL.
+ b.el('sign-in').click();
+ await waitFor(() => b.location.assigned, 'the redirect to Network');
+ const to = new URL(b.location.assigned);
+ assert.equal(`${to.origin}${to.pathname}`, 'https://openvibe.network/oauth/authorize');
+ assert.equal(to.searchParams.get('client_id'), APP);
+ assert.equal(to.searchParams.get('code_challenge_method'), 'S256');
+ assert.equal(to.searchParams.get('scope'), 'media.object.read');
+ const saved = JSON.parse(storage.get('ov_example_pkce'));
+ assert.equal(saved.state, to.searchParams.get('state'));
+
+ // 3. Network sends the browser back with a code (platform.authorize stands in for the consent page).
+ const code = platform.authorize({ clientId: APP, redirectUri: config.redirectUri, subjectId: ana.subject_id, codeChallenge: to.searchParams.get('code_challenge') });
+ b = load(`/callback?code=${code}&state=${encodeURIComponent(saved.state)}`);
+ await waitFor(() => b.el('who').textContent.startsWith('Signed in'), 'the signed-in session');
+ assert.match(b.el('who').textContent, new RegExp(`Signed in with OpenVibe as ${ana.subject_id}`));
+ assert.equal(b.location.pathname, '/', 'the code is removed from the address bar');
+ assert.equal(storage.has('ov_example_pkce'), false, 'the verifier is used once');
+ assert.match(jar.cookie, /^ov_browser_example_sid=/);
+ const exchange = platform.stats.requests.filter((r) => r.url.endsWith('/oauth/token'));
+ assert.equal(exchange.length, 1);
+
+ // 4. A callback this browser did not start: refused before any exchange.
+ storage.set('ov_example_pkce', JSON.stringify({ state: 'mine', codeVerifier: saved.codeVerifier }));
+ b = load('/callback?code=abc&state=forged');
+ await waitFor(() => b.el('auth-error').textContent !== '', 'the state error');
+ assert.match(b.el('auth-error').textContent, /oauth\.state_mismatch/);
+ assert.equal(platform.stats.requests.filter((r) => r.url.endsWith('/oauth/token')).length, 1);
+
+ // 5. When the browser refuses the cross-origin registry call, the page reads it through its server.
+ b = load('/', { crossOrigin: 'refuse' });
+ await waitFor(() => /through this app's server/.test(b.el('registry-status').textContent), 'the registry fallback');
+ assert.ok(b.el('services').children.length > 0);
+
+ // 6. The exchange endpoint accepts only this page: same Origin and a JSON body.
+ const cross = await realFetch(`${base}/auth/exchange`, { method: 'POST', headers: { origin: 'https://evil.example', 'content-type': 'application/json' }, body: '{}' });
+ assert.equal(cross.status, 403);
+ const form = await realFetch(`${base}/auth/exchange`, { method: 'POST', headers: { origin: config.origin, 'content-type': 'application/x-www-form-urlencoded' }, body: 'code=x' });
+ assert.equal(form.status, 415);
+
+ // 7. Sign out.
+ b = load('/');
+ await waitFor(() => b.el('who').textContent.startsWith('Signed in'), 'the session on reload');
+ b.el('sign-out').click();
+ await waitFor(() => b.el('who').textContent === 'Not signed in.', 'the sign-out');
+
+ assert.ok(!/eyJ[\w-]+\.[\w-]+\./.test(logs.join('\n')), 'no tokens in logs');
+ app.server.close();
+ console.log('browser-app: ok');
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+# Chat WebSocket (served on openvibe.live until openvibe.chat launches).
+OV_CHAT_WS_URL=wss://openvibe.live/ws/chat
+# A Live API token (hbt_..., scope "chat") created by a DEDICATED bot account whose name contains "bot".
+OV_CHAT_TOKEN=
+# Exactly one room: a channel (the streamer's user id) or a stream id.
+OV_CHAT_CHANNEL_USER_ID=
+OV_CHAT_STREAM_ID=
+OV_BOT_PREFIX="[bot] "
+OV_BOT_TRIGGER=?
+# Who runs the bot (shown by ?about).
+OV_BOT_OWNER=
+OV_BOT_MIN_INTERVAL_MS=2000
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+# Chat bot
+
+A small bot that answers `?ping`, `?help` and `?about` in **one** chat room, and says it is a bot
+every time it speaks.
+
+```bash
+node --env-file=.env bot.js
+# [bot] HelperBot joined channel 42
+```
+
+## What it proves
+
+Chat's WebSocket protocol (`/ws/chat`, as moved from Live into OpenVibe.Chat):
+
+```text
+-> { "type": "join", "token": "<hbt_…>", "channelUserId": 42 } (or "streamId": 1234)
+<- { "type": "auth", "authenticated": true, "username": "HelperBot", "core_username": "HelperBot", … }
+-> { "type": "chat", "message": "[bot] pong" } posts into the joined room
+<- { "type": "chat", "core_username": "ana", "message": "?ping", "channel_user_id": 42, "stream_id": null, … }
+<- { "type": "system", "message": "Slow down! …" }
+```
+
+And the rules a well-behaved bot keeps:
+
+- **One room.** It joins exactly the configured room (channel or stream), on every reconnect, and
+ never sends another join. A socket can only post into the room it joined, and the bot only
+ answers messages whose room matches its configuration; frames for other rooms or global chat
+ are ignored even if they reach it.
+- **Says it is a bot.** It refuses to run unless the account's name contains "bot", and every
+ line starts with `OV_BOT_PREFIX` (`[bot] ` by default). Because of the prefix nothing it posts
+ can start with `/` or `!`, so it can never run a chat command. `?about` says who runs it.
+- **Never anonymous.** If the token is not accepted, the server treats the socket as a guest; the
+ bot stops instead of chatting as one.
+- **No loops, no floods.** It ignores itself and anything starting with the bot prefix, answers
+ only known commands, keeps one line of at most 280 characters, paces replies
+ (`OV_BOT_MIN_INTERVAL_MS`) and drops rather than queues more than five pending replies.
+- **The token stays secret.** It goes in the join frame, never in the URL (URLs end up in access
+ logs), and is never logged.
+
+## Files
+
+| File | What |
+|---|---|
+| `bot.js` | `loadConfig()`, `createBot()` (`start`, `stop`, `say`, `on('ready'|'stop')`), `COMMANDS` |
+| `test/mock-chat-server.js` | a local `/ws/chat` that plays the frames above (the SDK mock has no Chat) |
+| `test/smoke.test.js` | room isolation, bot labelling, loop and command safety, pacing, reconnect, refusals |
+
+## Run the smoke test
+
+```bash
+npm test
+```
+
+Uses Node 22's built-in `WebSocket` for the bot; `ws` is a dev dependency for the mock server only.
+
+## Run it against the real platform
+
+1. Create a **dedicated account** for the bot whose username contains "bot" (for example
+ `HelperBot`). Do not use your own account.
+2. Signed in as that account on openvibe.live, create an API token with the `chat` scope
+ (Dashboard → API Tokens, or `POST https://openvibe.live/api/auth/tokens`; see
+ [API tokens](https://openvibe.live/docs/api-tokens)). It is shown once.
+3. `cp .env.example .env`, set `OV_CHAT_TOKEN`, and **one** of `OV_CHAT_CHANNEL_USER_ID` (the
+ streamer's user id: the channel room, live or offline) or `OV_CHAT_STREAM_ID`.
+4. `node --env-file=.env bot.js`, then type `?ping` in that channel's chat.
+
+Only use it in a channel whose streamer agreed to have it there; they can ban it like anyone else.
+
+Limits today:
+
+- **Not an app credential.** Chat has no bot or app principal: the bot signs in with a Live API
+ token, which is a scoped credential of a person's account, not a Network developer-app token.
+ `chat.message.send` is a `first-party` capability, so a developer app cannot be granted it.
+- **No bot flag in the protocol.** The `[bot]` prefix and the account name are the only way the
+ bot can identify itself; chat clients cannot mark bots differently yet.
+- Chat is still served on `openvibe.live` (`openvibe.chat` has not launched). The Live API-token
+ doc shows the token and stream in the URL query; this example uses the join frame instead.
new file mode 100644
new file mode 100644
@@ -0,0 +1,188 @@
@@ -0,0 +1,188 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Chat bot: a small, clearly labelled bot that answers commands in ONE chat room.
+ *
+ * node --env-file=.env bot.js
+ *
+ * Protocol (OpenVibe.Chat's /ws/chat, served on openvibe.live until openvibe.chat launches):
+ * -> { type: 'join', token, channelUserId } or { type: 'join', token, streamId }
+ * <- { type: 'auth', authenticated, username, core_username, role, user_id, ... }
+ * -> { type: 'chat', message } posts into the room this socket joined
+ * <- { type: 'chat', username, core_username, message, stream_id, channel_user_id, is_global, ... }
+ * <- { type: 'system', message } slow mode, bans, ...
+ *
+ * Rules this bot keeps, whatever it receives:
+ * - It joins exactly the room in its configuration, on every (re)connect, and never another.
+ * A socket posts only into the room it joined, and the bot only answers messages whose room
+ * matches its configuration.
+ * - It identifies as a bot: it refuses to run unless the account's name says "bot", and every
+ * line it posts starts with OV_BOT_PREFIX (default "[bot] "), which also means nothing it
+ * posts can ever start with "/" or "!" and be run as a chat command.
+ * - It never runs anonymously: if the token is not accepted it stops instead of chatting as a guest.
+ * - It never answers itself or other bots (lines starting with the prefix), and it paces its
+ * replies (OV_BOT_MIN_INTERVAL_MS) below the server's slow mode.
+ * - The token is sent in the join frame, never in the URL, and never logged.
+ *
+ * Credentials: a Live API token (hbt_…, scope `chat`) created by a DEDICATED bot account on its
+ * dashboard (see docs on openvibe.live/docs/api-tokens). Chat has no app-principal bot login yet.
+ */
+
+const COMMANDS = {
+ ping: () => 'pong',
+ help: (bot) => `commands: ${Object.keys(COMMANDS).map((c) => `${bot.config.trigger}${c}`).join(', ')}`,
+ about: (bot) => `I am an automated bot${bot.config.owner ? ` run by ${bot.config.owner}` : ''}. I only talk in this room and only answer ${bot.config.trigger}commands.`,
+};
+
+function loadConfig(env = process.env) {
+ const missing = ['OV_CHAT_TOKEN'].filter((k) => !env[k]);
+ if (!env.OV_CHAT_CHANNEL_USER_ID === !env.OV_CHAT_STREAM_ID) missing.push('exactly one of OV_CHAT_CHANNEL_USER_ID / OV_CHAT_STREAM_ID');
+ if (missing.length) throw Object.assign(new Error(`missing configuration: ${missing.join(', ')} (see .env.example)`), { code: 'config.missing' });
+ const room = env.OV_CHAT_CHANNEL_USER_ID
+ ? { kind: 'channel', id: Number(env.OV_CHAT_CHANNEL_USER_ID) }
+ : { kind: 'stream', id: Number(env.OV_CHAT_STREAM_ID) };
+ if (!Number.isInteger(room.id) || room.id <= 0) throw Object.assign(new Error('the room id must be a positive integer'), { code: 'config.invalid' });
+ const prefix = env.OV_BOT_PREFIX === undefined ? '[bot] ' : env.OV_BOT_PREFIX;
+ if (!prefix.trim() || /^[/!]/.test(prefix)) throw Object.assign(new Error('OV_BOT_PREFIX must be visible text and must not start with / or !'), { code: 'config.invalid' });
+ const trigger = env.OV_BOT_TRIGGER || '?';
+ if (/^[/!]/.test(trigger)) throw Object.assign(new Error('OV_BOT_TRIGGER must not start with / or ! (the server runs those as commands)'), { code: 'config.invalid' });
+ return {
+ wsUrl: env.OV_CHAT_WS_URL || 'wss://openvibe.live/ws/chat',
+ token: env.OV_CHAT_TOKEN,
+ room,
+ prefix,
+ trigger,
+ owner: env.OV_BOT_OWNER || '',
+ minIntervalMs: Number(env.OV_BOT_MIN_INTERVAL_MS || 2000),
+ maxLength: 280,
+ };
+}
+
+function createBot(config, { WebSocket = globalThis.WebSocket, log = console } = {}) {
+ if (typeof WebSocket !== 'function') throw new TypeError('no WebSocket implementation (Node 22+ has one built in)');
+ const joinFrame = Object.freeze(config.room.kind === 'channel'
+ ? { type: 'join', token: config.token, channelUserId: config.room.id }
+ : { type: 'join', token: config.token, streamId: config.room.id });
+ const state = { ws: null, ready: false, me: null, stopped: false, failure: null, backoffMs: 1000, queue: [], lastSentAt: 0, timer: null };
+ const listeners = { ready: [], stop: [] };
+ const emit = (name, arg) => { for (const fn of listeners[name]) fn(arg); };
+
+ /** Is this chat frame from the room the bot is configured for? */
+ function inMyRoom(msg) {
+ if (msg.is_global) return false;
+ if (config.room.kind === 'stream') return Number(msg.stream_id) === config.room.id;
+ return Number(msg.channel_user_id) === config.room.id;
+ }
+
+ /** One line, no control characters, labelled, bounded. */
+ function format(text) {
+ const clean = String(text).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
+ const room = config.maxLength - config.prefix.length;
+ return `${config.prefix}${clean.length > room ? `${clean.slice(0, room - 1)}…` : clean}`;
+ }
+
+ function flush() {
+ clearTimeout(state.timer);
+ state.timer = null;
+ if (!state.queue.length || !state.ready || !state.ws || state.ws.readyState !== WebSocket.OPEN) return;
+ const wait = state.lastSentAt + config.minIntervalMs - Date.now();
+ if (wait > 0) { state.timer = setTimeout(flush, wait); return; }
+ const line = state.queue.shift();
+ state.ws.send(JSON.stringify({ type: 'chat', message: line }));
+ state.lastSentAt = Date.now();
+ if (state.queue.length) state.timer = setTimeout(flush, config.minIntervalMs);
+ }
+
+ /** Queue a line for the configured room. Refused unless signed in and joined. */
+ function say(text) {
+ if (!state.ready) throw Object.assign(new Error('not joined yet'), { code: 'bot.not_ready' });
+ if (state.queue.length >= 5) return false; // never build a backlog to flood with later
+ state.queue.push(format(text));
+ flush();
+ return true;
+ }
+
+ function onChat(msg) {
+ if (!inMyRoom(msg)) return;
+ const text = String(msg.message || '');
+ if (msg.core_username && state.me && msg.core_username.toLowerCase() === state.me.toLowerCase()) return;
+ if (text.startsWith(config.prefix.trim())) return; // another bot: never answer bots
+ if (!text.startsWith(config.trigger)) return;
+ const name = text.slice(config.trigger.length).split(/\s+/)[0].toLowerCase();
+ if (!Object.prototype.hasOwnProperty.call(COMMANDS, name)) return;
+ say(COMMANDS[name](api, msg));
+ }
+
+ function stop(failure) {
+ state.stopped = true;
+ state.ready = false;
+ state.failure = failure || null;
+ clearTimeout(state.timer);
+ if (state.ws) { try { state.ws.close(); } catch { /* closed */ } }
+ emit('stop', failure || null);
+ }
+
+ function connect() {
+ if (state.stopped) return;
+ const ws = new WebSocket(config.wsUrl);
+ state.ws = ws;
+ ws.addEventListener('open', () => ws.send(JSON.stringify(joinFrame)));
+ ws.addEventListener('message', (ev) => {
+ let msg;
+ try { msg = JSON.parse(typeof ev.data === 'string' ? ev.data : Buffer.from(ev.data).toString('utf8')); } catch { return; }
+ if (msg.type === 'auth') {
+ if (!msg.authenticated) {
+ log.error('[bot] the chat token was not accepted; a bot never posts anonymously, stopping');
+ return stop({ code: 'bot.not_authenticated' });
+ }
+ if (!/bot/i.test(`${msg.core_username || ''} ${msg.username || ''}`)) {
+ log.error(`[bot] "${msg.core_username}" does not say it is a bot; use a dedicated bot account, stopping`);
+ return stop({ code: 'bot.name_required' });
+ }
+ state.me = msg.core_username || msg.username;
+ state.ready = true;
+ state.backoffMs = 1000;
+ log.log(`[bot] ${state.me} joined ${config.room.kind} ${config.room.id}`);
+ emit('ready', { username: state.me });
+ return flush();
+ }
+ if (msg.type === 'chat') return onChat(msg);
+ if (msg.type === 'system' && msg.message) log.log(`[bot] system: ${msg.message}`);
+ return undefined;
+ });
+ ws.addEventListener('close', () => {
+ state.ready = false;
+ if (state.stopped) return;
+ const wait = state.backoffMs;
+ state.backoffMs = Math.min(30000, state.backoffMs * 2);
+ log.log(`[bot] disconnected; reconnecting in ${wait} ms`);
+ setTimeout(connect, wait);
+ });
+ ws.addEventListener('error', () => { /* close follows */ });
+ }
+
+ const api = {
+ config,
+ start() { state.stopped = false; connect(); return api; },
+ stop: () => stop(null),
+ say,
+ on(name, fn) { listeners[name].push(fn); return api; },
+ get ready() { return state.ready; },
+ get failure() { return state.failure; },
+ get username() { return state.me; },
+ _setBackoff(ms) { state.backoffMs = ms; },
+ };
+ return api;
+}
+
+if (require.main === module) {
+ let config;
+ try { config = loadConfig(); } catch (err) { console.error(err.message); process.exit(2); }
+ const bot = createBot(config).start();
+ bot.on('stop', (failure) => { if (failure) process.exitCode = 1; });
+ const quit = () => { bot.stop(); setTimeout(() => process.exit(), 100); };
+ process.on('SIGINT', quit);
+ process.on('SIGTERM', quit);
+}
+
+module.exports = { loadConfig, createBot, COMMANDS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "name": "openvibe-example-chat-bot",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Chat bot: a clearly labelled bot that talks in exactly one configured chat room.",
+ "license": "MIT",
+ "main": "bot.js",
+ "scripts": {
+ "start": "node --env-file-if-exists=.env bot.js",
+ "test": "node test/smoke.test.js"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "dependencies": {},
+ "devDependencies": {
+ "ws": "^8.18.0"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+'use strict';
+/**
+ * A small local stand-in for OpenVibe.Chat's /ws/chat, for tests only (the SDK's mock platform has
+ * no Chat). It plays the documented frames: join (token + channelUserId | streamId) -> auth;
+ * chat -> broadcast to the sockets in the same room; system for slow mode. A socket posts only
+ * into the room it joined, as on the real server.
+ */
+const http = require('node:http');
+const { WebSocketServer } = require('ws');
+
+function createMockChatServer({ tokens = {}, streams = {}, slowMs = 0 } = {}) {
+ const server = http.createServer((_req, res) => { res.writeHead(404); res.end(); });
+ const wss = new WebSocketServer({ server, path: '/ws/chat' });
+ const clients = new Map(); // ws -> { user, streamId, channelUserId, lastChatAt }
+ const record = { joins: [], posts: [], urls: [] };
+
+ const roomOf = (c) => `${c.streamId || 0}:${c.channelUserId || 0}`;
+ const send = (ws, frame) => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(frame)); };
+
+ wss.on('connection', (ws, req) => {
+ record.urls.push(req.url);
+ clients.set(ws, { user: null, streamId: null, channelUserId: null, lastChatAt: 0 });
+ ws.on('message', (data) => {
+ let msg;
+ try { msg = JSON.parse(String(data)); } catch { return; }
+ const c = clients.get(ws);
+ if (msg.type === 'join') {
+ if (msg.token && tokens[msg.token]) c.user = tokens[msg.token];
+ c.streamId = parseInt(msg.streamId, 10) || null;
+ c.channelUserId = c.streamId ? (streams[c.streamId] || null) : (parseInt(msg.channelUserId, 10) || null);
+ record.joins.push({ user: c.user && c.user.username, streamId: c.streamId, channelUserId: c.channelUserId });
+ send(ws, { type: 'auth', authenticated: Boolean(c.user), username: c.user ? c.user.username : 'anon1234', core_username: c.user ? c.user.username : null, role: c.user ? 'user' : 'anon', user_id: c.user ? c.user.id : null });
+ return;
+ }
+ if (msg.type === 'chat') {
+ const text = String(msg.message || '').trim();
+ if (!text) return;
+ if (slowMs && Date.now() - c.lastChatAt < slowMs) return send(ws, { type: 'system', message: 'Slow down! You are sending messages too fast.' });
+ c.lastChatAt = Date.now();
+ const frame = {
+ type: 'chat', username: c.user ? c.user.username : 'anon1234', core_username: c.user ? c.user.username : null,
+ user_id: c.user ? c.user.id : null, message: text, stream_id: c.streamId, channel_user_id: c.channelUserId,
+ is_global: !c.streamId && !c.channelUserId, timestamp: new Date().toISOString(),
+ };
+ record.posts.push({ room: roomOf(c), username: frame.username, message: text });
+ for (const [other, oc] of clients) if (roomOf(oc) === roomOf(c)) send(other, frame);
+ }
+ return undefined;
+ });
+ ws.on('close', () => clients.delete(ws));
+ });
+
+ return {
+ record,
+ async listen() {
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ return `ws://127.0.0.1:${server.address().port}/ws/chat`;
+ },
+ /** Send a raw frame to every socket (e.g. a frame for another room, to prove the bot ignores it). */
+ pushToAll(frame) { for (const ws of clients.keys()) send(ws, frame); },
+ /** Drop every connection (the bot must reconnect and rejoin its own room). */
+ dropAll() { for (const ws of clients.keys()) ws.terminate(); },
+ close: () => new Promise((r) => { for (const ws of clients.keys()) ws.terminate(); wss.close(); server.close(r); }),
+ };
+}
+
+module.exports = { createMockChatServer };
new file mode 100644
new file mode 100644
@@ -0,0 +1,144 @@
@@ -0,0 +1,144 @@
+'use strict';
+/** Smoke test: the bot against the local /ws/chat mock (the SDK's mock platform has no Chat). */
+const assert = require('node:assert/strict');
+const { createMockChatServer } = require('./mock-chat-server');
+const { createBot, loadConfig } = require('../bot');
+
+globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
+
+const BOT_TOKEN = 'hbt_helperbot_test_token_value';
+const quiet = () => { const lines = []; return { lines, log: (m) => lines.push(m), error: (m) => lines.push(m) }; };
+
+async function waitFor(check, what, ms = 3000) {
+ const until = Date.now() + ms;
+ while (Date.now() < until) {
+ if (check()) return;
+ await new Promise((r) => setTimeout(r, 10));
+ }
+ throw new Error(`timed out waiting for ${what}`);
+}
+
+/** A person in a room, over the same protocol. */
+async function person(url, token, room) {
+ const ws = new WebSocket(url);
+ const seen = [];
+ ws.addEventListener('message', (ev) => seen.push(JSON.parse(ev.data)));
+ await new Promise((r) => ws.addEventListener('open', r));
+ ws.send(JSON.stringify({ type: 'join', token, ...room }));
+ await waitFor(() => seen.some((m) => m.type === 'auth'), 'the join');
+ return { say: (message) => ws.send(JSON.stringify({ type: 'chat', message })), seen, close: () => ws.close() };
+}
+
+(async () => {
+ const chat = createMockChatServer({
+ tokens: {
+ [BOT_TOKEN]: { id: 99, username: 'HelperBot' },
+ hbt_other_bot: { id: 98, username: 'OtherBot' },
+ hbt_ana: { id: 1, username: 'ana' },
+ hbt_bo: { id: 2, username: 'bo' },
+ },
+ streams: { 555: 7 },
+ });
+ const url = await chat.listen();
+ const env = { OV_CHAT_WS_URL: url, OV_CHAT_TOKEN: BOT_TOKEN, OV_CHAT_CHANNEL_USER_ID: '7', OV_BOT_MIN_INTERVAL_MS: '40', OV_BOT_OWNER: 'example.dev' };
+ const out = quiet();
+ const bot = createBot(loadConfig(env), { log: out }).start();
+ await waitFor(() => bot.ready, 'the bot to join');
+ assert.equal(bot.username, 'HelperBot');
+
+ const ana = await person(url, 'hbt_ana', { channelUserId: 7 });
+ const bo = await person(url, 'hbt_bo', { channelUserId: 8 });
+ const botPosts = () => chat.record.posts.filter((p) => p.username === 'HelperBot');
+
+ // 1. A command in its room is answered there, labelled as a bot.
+ ana.say('?ping');
+ await waitFor(() => botPosts().length === 1, 'the pong');
+ assert.deepEqual(botPosts()[0], { room: '0:7', username: 'HelperBot', message: '[bot] pong' });
+
+ // 2. The same command in another room is not answered, and the bot never posts there.
+ bo.say('?ping');
+ await new Promise((r) => setTimeout(r, 150));
+ assert.equal(botPosts().length, 1);
+ assert.ok(chat.record.posts.filter((p) => p.room === '0:8').every((p) => p.username === 'bo'));
+
+ // 3. A frame about another room delivered to the bot's socket anyway: ignored.
+ chat.pushToAll({ type: 'chat', username: 'mallory', core_username: 'mallory', message: '?ping', channel_user_id: 8, stream_id: null, is_global: false });
+ chat.pushToAll({ type: 'chat', username: 'mallory', core_username: 'mallory', message: '?ping', channel_user_id: null, stream_id: null, is_global: true });
+ await new Promise((r) => setTimeout(r, 150));
+ assert.equal(botPosts().length, 1);
+
+ // 4. It never answers itself or another bot, and ignores unknown commands and ! / commands.
+ const other = await person(url, 'hbt_other_bot', { channelUserId: 7 });
+ other.say('[bot] ?ping');
+ ana.say('?nope');
+ ana.say('!ping');
+ await new Promise((r) => setTimeout(r, 150));
+ assert.equal(botPosts().length, 1);
+
+ // 5. help / about identify it; replies are paced but not dropped.
+ ana.say('?help');
+ await new Promise((r) => setTimeout(r, 5));
+ ana.say('?about');
+ await waitFor(() => botPosts().length === 3, 'help and about');
+ assert.equal(botPosts()[1].message, '[bot] commands: ?ping, ?help, ?about');
+ assert.equal(botPosts()[2].message, '[bot] I am an automated bot run by example.dev. I only talk in this room and only answer ?commands.');
+
+ // 6. say() is one line, labelled, bounded; a leading "/" can never become a command.
+ bot.say('/ban bo\nnow');
+ await waitFor(() => botPosts().length === 4, 'the formatted line');
+ assert.equal(botPosts()[3].message, '[bot] /ban bo now');
+ bot.say('x'.repeat(500));
+ await waitFor(() => botPosts().length === 5, 'the long line');
+ assert.equal(botPosts()[4].message.length, 280);
+
+ // 7. Dropped connection: it reconnects and rejoins ITS room only.
+ bot._setBackoff(20);
+ chat.dropAll();
+ await waitFor(() => !bot.ready, 'the drop');
+ await waitFor(() => bot.ready, 'the reconnect');
+ const ana2 = await person(url, 'hbt_ana', { channelUserId: 7 });
+ ana2.say('?ping');
+ await waitFor(() => botPosts().length === 6, 'the pong after reconnect');
+ const botJoins = chat.record.joins.filter((j) => j.user === 'HelperBot');
+ assert.ok(botJoins.length >= 2);
+ assert.ok(botJoins.every((j) => j.channelUserId === 7 && j.streamId === null), 'every join is the configured room');
+
+ // 8. The token never appears in a URL or a log line.
+ assert.ok(chat.record.urls.every((u) => !u.includes(BOT_TOKEN) && !u.includes('token')));
+ assert.ok(!out.lines.join('\n').includes(BOT_TOKEN));
+ bot.stop();
+
+ // 9. Stream rooms: configured by stream id, answers only that stream's messages.
+ const streamBot = createBot(loadConfig({ ...env, OV_CHAT_CHANNEL_USER_ID: '', OV_CHAT_STREAM_ID: '555' }), { log: quiet() }).start();
+ await waitFor(() => streamBot.ready, 'the stream bot');
+ const viewer = await person(url, 'hbt_ana', { streamId: 555 });
+ const before = botPosts().length;
+ viewer.say('?ping');
+ await waitFor(() => botPosts().length === before + 1, 'the stream pong');
+ assert.equal(botPosts()[before].room, '555:7');
+ streamBot.stop();
+
+ // 10. A token that is not accepted: the bot stops instead of chatting anonymously.
+ const anon = createBot(loadConfig({ ...env, OV_CHAT_TOKEN: 'hbt_revoked' }), { log: quiet() });
+ const stopped = new Promise((r) => anon.on('stop', r));
+ anon.start();
+ assert.deepEqual(await stopped, { code: 'bot.not_authenticated' });
+
+ // 11. An account whose name does not say "bot": refused.
+ const person1 = createBot(loadConfig({ ...env, OV_CHAT_TOKEN: 'hbt_ana' }), { log: quiet() });
+ const refused = new Promise((r) => person1.on('stop', r));
+ person1.start();
+ assert.deepEqual(await refused, { code: 'bot.name_required' });
+
+ // 12. Configuration guards.
+ assert.throws(() => loadConfig({ OV_CHAT_TOKEN: 't' }), /exactly one/);
+ assert.throws(() => loadConfig({ OV_CHAT_TOKEN: 't', OV_CHAT_CHANNEL_USER_ID: '7', OV_CHAT_STREAM_ID: '5' }), /exactly one/);
+ assert.throws(() => loadConfig({ ...env, OV_BOT_PREFIX: '' }), /prefix/i);
+ assert.throws(() => loadConfig({ ...env, OV_BOT_PREFIX: '/me ' }), /prefix/i);
+ assert.throws(() => loadConfig({ ...env, OV_BOT_TRIGGER: '!' }), /trigger/i);
+
+ for (const p of [ana, bo, other, ana2, viewer]) p.close();
+ await chat.close();
+ console.log('chat-bot: ok');
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+# realtime: SSE, works without credentials for public events. pull: needs events.event.read.
+OV_EVENTS_MODE=realtime
+OV_TOPICS=chat.message.created
+OV_CURSOR_PATH=./data/cursor.json
+OV_POLL_MS=5000
+OV_NETWORK_URL=https://openvibe.network
+# Optional: an explicit Events origin instead of the one the registry advertises.
+OV_EVENTS_URL=
+# Only for pull mode (or to read non-public events over realtime).
+OV_CLIENT_ID=
+OV_CLIENT_SECRET=
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+# Event subscriber
+
+Follow OpenVibe.Events topics without losing or double-handling events, across dropped
+connections, restarts and retention gaps.
+
+```bash
+OV_TOPICS='chat.message.created' node --env-file=.env subscriber.js # realtime, no credentials
+OV_EVENTS_MODE=pull node --env-file=.env subscriber.js # pull, needs events.event.read
+```
+
+## What it proves
+
+- **Realtime** (`openvibe-sdk/realtime` `subscribe()`): SSE from `/realtime/stream`. Without
+ credentials you receive `public` events only; `internal` events never reach you. After a
+ dropped connection or a restart it resumes from the saved cursor with `Last-Event-ID`, and
+ nothing is delivered twice.
+- **Pull** (`openvibe-sdk/events` `iterate()`): pages from the saved cursor to the head. The
+ cursor is saved after each handled event, and moved past non-matching events (the page's
+ `next_after_seq`) only once every item of that page is handled. A handler that throws leaves
+ the cursor on the last event that succeeded, so the failed event is retried next time.
+- **Gaps**: when Events can no longer supply part of the range (retention pruned it, or a restored
+ cursor is ahead of the stream), `onResync(gap)` is called. Nothing can replay the missing events,
+ so reload whatever state you derive from them from its source of truth.
+- The cursor file is replaced atomically (write + rename).
+
+## Files
+
+| File | What |
+|---|---|
+| `subscriber.js` | `createSubscriber()` (`start()`, `stop()`, `pullOnce()`), `createCursorStore()` |
+| `test/smoke.test.js` | pull with cursor and failure, pull gap, anonymous realtime, reconnect, restart, cursor-ahead gap |
+
+## Run the smoke test
+
+```bash
+npm test
+```
+
+The SDK mock never prunes events, so the pull-gap case wraps the mock's `fetch` to add the `gap`
+field Events returns after pruning. Realtime gaps come from the mock itself (`cursor_ahead`).
+
+## Run it against the real platform
+
+- **Realtime works without an app**: `cp .env.example .env`, keep `OV_EVENTS_MODE=realtime`, set
+ `OV_TOPICS`, run `node --env-file=.env subscriber.js`. The stream is
+ `https://events.openvibe.network/realtime/stream` (from the registry). You will only see
+ events that a producer publishes with visibility `public` on those topics. Chat's
+ `chat.message.created` is public, but Chat has not been cut over yet, so whether anything
+ arrives depends on which producers are live.
+- **Pull cannot be demonstrated yet.** `events.event.read` is an `internal` capability (never
+ granted to apps), and the pull API (`/api/v1/events`, checkpoints) is not exposed on Events'
+ public host. Checkpoints stored in Events are therefore not used here: the cursor is a local file.
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+{
+ "name": "openvibe-example-event-subscriber",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Event subscriber: realtime subscription and cursor-based pull with gap handling.",
+ "license": "MIT",
+ "main": "subscriber.js",
+ "scripts": {
+ "start": "node --env-file-if-exists=.env subscriber.js",
+ "test": "node test/smoke.test.js"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,190 @@
@@ -0,0 +1,190 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Event subscriber: follow OpenVibe.Events topics without losing or double-handling events.
+ *
+ * node --env-file=.env subscriber.js
+ *
+ * Two ways in, one cursor discipline:
+ *
+ * realtime GET /realtime/stream (SSE) through openvibe-sdk/realtime subscribe(). Works without
+ * credentials for events whose visibility is `public`. Resumes with Last-Event-ID from
+ * the saved cursor after a restart or a dropped connection.
+ * pull GET /api/v1/events through openvibe-sdk/events iterate(), from the saved cursor.
+ * Needs an app token holding events.event.read.
+ *
+ * The cursor is saved only AFTER an event was handled, so a crash replays at most the event in
+ * hand (make your handler idempotent on event_id). In pull mode the cursor also moves past events
+ * that did not match the topic, to the page's next_after_seq, once every item of that page is done.
+ *
+ * A gap means Events can no longer give you part of the range (retention pruned it, or the cursor
+ * is ahead of the stream after a restore). Nothing can replay it, so the subscriber calls
+ * onResync(gap): reload whatever state you derive from these events from its source of truth.
+ */
+const fs = require('node:fs');
+const path = require('node:path');
+const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+const { createEventsClient } = require('openvibe-sdk/events');
+const { subscribe } = require('openvibe-sdk/realtime');
+
+function loadConfig(env = process.env) {
+ const mode = env.OV_EVENTS_MODE || 'realtime';
+ if (!['realtime', 'pull'].includes(mode)) throw Object.assign(new Error('OV_EVENTS_MODE must be realtime or pull'), { code: 'config.invalid' });
+ const hasCreds = Boolean(env.OV_CLIENT_ID && env.OV_CLIENT_SECRET);
+ if (mode === 'pull' && !hasCreds) {
+ throw Object.assign(new Error('pull mode needs OV_CLIENT_ID and OV_CLIENT_SECRET (see .env.example)'), { code: 'config.missing' });
+ }
+ return {
+ mode,
+ network: env.OV_NETWORK_URL || 'https://openvibe.network',
+ clientId: env.OV_CLIENT_ID || null,
+ clientSecret: env.OV_CLIENT_SECRET || null,
+ eventsUrl: env.OV_EVENTS_URL || null,
+ topics: String(env.OV_TOPICS || 'chat.message.created').split(',').map((t) => t.trim()).filter(Boolean),
+ cursorPath: env.OV_CURSOR_PATH || path.join(__dirname, 'data', 'cursor.json'),
+ pollMs: Number(env.OV_POLL_MS || 5000),
+ };
+}
+
+/** A tiny durable cursor: { realtime: seq, pull: seq } in a JSON file, replaced atomically. */
+function createCursorStore(file) {
+ let state = {};
+ try { state = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch { state = {}; }
+ return {
+ get: (name) => (Number.isFinite(state[name]) ? state[name] : null),
+ set(name, seq) {
+ if (!Number.isFinite(seq) || seq === state[name]) return;
+ state = { ...state, [name]: seq };
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ const tmp = `${file}.${process.pid}.tmp`;
+ fs.writeFileSync(tmp, JSON.stringify(state));
+ fs.renameSync(tmp, file);
+ },
+ };
+}
+
+/**
+ * createSubscriber(config, { onEvent, onResync, fetch, log })
+ * onEvent(event, { seq, via }) handle one event (may be async)
+ * onResync(gap, { via }) state derived from events may be stale: reload it
+ */
+function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = console, cursor = createCursorStore(config.cursorPath) } = {}) {
+ if (typeof onEvent !== 'function') throw new TypeError('onEvent is required');
+ const tokenProvider = config.clientId && config.clientSecret
+ ? createServiceTokenClient({ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch })
+ : undefined;
+ const client = createClient({
+ network: config.network,
+ fetch,
+ tokenProvider,
+ baseUrls: config.eventsUrl ? { events: config.eventsUrl } : undefined,
+ });
+ const stats = { handled: 0, gaps: 0 };
+
+ async function gap(g, via) {
+ stats.gaps++;
+ log.log(`[events] gap via ${via}: ${g.reason || 'pruned'} seq ${g.from_seq}..${g.to_seq}; resyncing`);
+ await onResync(g, { via });
+ }
+
+ // ── pull ────────────────────────────────────────────────
+ const events = createEventsClient(client);
+ /** Read from the saved cursor to the head once. Returns the number of events handled. */
+ async function pullOnce({ limit = 100 } = {}) {
+ let pageNext = null;
+ let handled = 0;
+ const iterator = events.iterate({
+ topic: config.topics,
+ afterSeq: cursor.get('pull') || 0,
+ limit,
+ onGap: (g) => gap(g, 'pull'),
+ // Called when a page arrives, before its items are handed out: the previous page is done.
+ onPage: (page) => {
+ if (pageNext != null) cursor.set('pull', pageNext);
+ pageNext = page.next_after_seq;
+ },
+ });
+ for await (const { seq, event } of iterator) {
+ await onEvent(event, { seq, via: 'pull' });
+ cursor.set('pull', seq);
+ handled++;
+ stats.handled++;
+ }
+ if (pageNext != null) cursor.set('pull', pageNext);
+ return handled;
+ }
+
+ let stopped = false;
+ let timer = null;
+ let sub = null;
+
+ async function pollLoop() {
+ while (!stopped) {
+ try { await pullOnce(); } catch (err) {
+ log.error(`[events] pull failed: ${isOpenVibeError(err) ? `${err.code} request=${err.requestId}` : err.message}`);
+ }
+ if (stopped) break;
+ await new Promise((r) => { timer = setTimeout(r, config.pollMs); });
+ }
+ }
+
+ // ── realtime ────────────────────────────────────────────
+ function startRealtime() {
+ let chain = Promise.resolve();
+ sub = subscribe(config.topics, (event, { seq }) => {
+ // Handle strictly in order; save the cursor only after the handler finished.
+ chain = chain.then(async () => {
+ await onEvent(event, { seq, via: 'realtime' });
+ cursor.set('realtime', seq);
+ stats.handled++;
+ }).catch((err) => log.error(`[events] handler failed at seq ${seq}: ${err.message}`));
+ }, {
+ client,
+ fetch,
+ transport: 'fetch',
+ lastEventId: cursor.get('realtime'),
+ onGap: (g) => { chain = chain.then(() => gap(g, 'realtime')); },
+ onOpen: () => log.log(`[events] realtime connected (${config.topics.join(', ')}) from seq ${sub ? sub.lastEventId : cursor.get('realtime')}`),
+ onError: (err) => log.error(`[events] realtime: ${isOpenVibeError(err) ? err.code : err.message}`),
+ });
+ return sub;
+ }
+
+ return {
+ client,
+ stats,
+ cursor,
+ pullOnce,
+ start() {
+ stopped = false;
+ if (config.mode === 'pull') { pollLoop(); return null; }
+ return startRealtime();
+ },
+ stop() {
+ stopped = true;
+ clearTimeout(timer);
+ if (sub) sub.close();
+ },
+ get subscription() { return sub; },
+ };
+}
+
+if (require.main === module) {
+ let config;
+ try { config = loadConfig(); } catch (err) { console.error(err.message); process.exit(2); }
+ const s = createSubscriber(config, {
+ onEvent(event, { seq, via }) {
+ console.log(`${via} #${seq} ${event.event_type} ${event.event_id} subject=${event.subject && `${event.subject.type}:${event.subject.id}`}`);
+ },
+ onResync(gap) {
+ console.log(`resync needed: events ${gap.from_seq}..${gap.to_seq} are gone; reload your state from its source`);
+ },
+ });
+ s.start();
+ const stop = () => { s.stop(); process.exit(0); };
+ process.on('SIGINT', stop);
+ process.on('SIGTERM', stop);
+}
+
+module.exports = { loadConfig, createCursorStore, createSubscriber };
new file mode 100644
new file mode 100644
@@ -0,0 +1,141 @@
@@ -0,0 +1,141 @@
+'use strict';
+/**
+ * Smoke test: pull with a durable cursor, realtime with resume, and gap handling in both, against
+ * the SDK's mock platform (fake Network + Events, including /realtime/stream). No network.
+ */
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { createMockPlatform } = require('openvibe-sdk/testing');
+const { createSubscriber, loadConfig, createCursorStore } = require('../subscriber');
+
+globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
+
+const APP = 'app_01JEXAMPLESUBSCRIBER000000';
+const SECRET = 'ovsec_event_subscriber_test_secret';
+const quiet = { log() {}, error() {} };
+
+async function waitFor(check, what, ms = 3000) {
+ const until = Date.now() + ms;
+ while (Date.now() < until) {
+ if (check()) return;
+ await new Promise((r) => setTimeout(r, 10));
+ }
+ throw new Error(`timed out waiting for ${what}`);
+}
+
+const media = (visibility = 'public', type = 'media.object.uploaded') => ({
+ event_type: type, source: 'media', visibility,
+ actor: { type: 'service', id: 'media' }, subject: { type: 'object', id: 'med_01JEXAMPLEOBJECT0000000000' },
+});
+
+(async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-subscriber-'));
+ const platform = createMockPlatform({
+ clients: { [APP]: { secret: SECRET, grants: [{ capability: 'events.event.read', audience: 'openvibe.events' }] } },
+ });
+
+ // ── pull: durable cursor ────────────────────────────────
+ const a = platform.publishEvent(media());
+ platform.publishEvent(media('public', 'chat.message.created')); // another topic: skipped, cursor moves past it
+ const b = platform.publishEvent(media('internal', 'media.object.deleted'));
+ const pullEnv = { OV_EVENTS_MODE: 'pull', OV_CLIENT_ID: APP, OV_CLIENT_SECRET: SECRET, OV_TOPICS: 'media.object.*', OV_CURSOR_PATH: path.join(dir, 'pull.json') };
+ let seen = [];
+ let s = createSubscriber(loadConfig(pullEnv), { fetch: platform.fetch, log: quiet, onEvent: (e, { seq }) => { seen.push(seq); } });
+ assert.equal(await s.pullOnce({ limit: 2 }), 2);
+ assert.deepEqual(seen, [a.seq, b.seq]);
+ assert.equal(createCursorStore(pullEnv.OV_CURSOR_PATH).get('pull'), 3, 'cursor saved at the head');
+
+ // A new process resumes from the cursor: only what is new.
+ const c = platform.publishEvent(media());
+ seen = [];
+ s = createSubscriber(loadConfig(pullEnv), { fetch: platform.fetch, log: quiet, onEvent: (e, { seq }) => { seen.push(seq); } });
+ assert.equal(await s.pullOnce(), 1);
+ assert.deepEqual(seen, [c.seq]);
+
+ // A handler that fails leaves the cursor on the last event that succeeded.
+ const d = platform.publishEvent(media());
+ const e = platform.publishEvent(media());
+ seen = [];
+ s = createSubscriber(loadConfig(pullEnv), {
+ fetch: platform.fetch, log: quiet,
+ onEvent: (ev, { seq }) => { if (seq === e.seq) throw new Error('boom'); seen.push(seq); },
+ });
+ await assert.rejects(s.pullOnce(), /boom/);
+ assert.equal(createCursorStore(pullEnv.OV_CURSOR_PATH).get('pull'), d.seq);
+ s = createSubscriber(loadConfig(pullEnv), { fetch: platform.fetch, log: quiet, onEvent: (ev, { seq }) => { seen.push(seq); } });
+ await s.pullOnce();
+ assert.deepEqual(seen, [d.seq, e.seq], 'the failed event is retried, nothing is skipped');
+
+ // A gap (retention pruned the range): onResync is told, reading continues. The mock never
+ // prunes, so this wraps its fetch to answer the way Events does after pruning.
+ const gapFetch = async (input, init) => {
+ const res = await platform.fetch(input, init);
+ const url = String(input && input.url ? input.url : input);
+ if (!url.includes('/api/v1/events?')) return res;
+ const body = await res.json();
+ return new Response(JSON.stringify({ ...body, gap: { from_seq: 7, to_seq: 9, reason: 'retention' } }), { status: res.status, headers: { 'Content-Type': 'application/json' } });
+ };
+ const f = platform.publishEvent(media());
+ const resyncs = [];
+ seen = [];
+ s = createSubscriber(loadConfig(pullEnv), { fetch: gapFetch, log: quiet, onEvent: (ev, { seq }) => { seen.push(seq); }, onResync: (g, { via }) => resyncs.push({ ...g, via }) });
+ await s.pullOnce();
+ assert.deepEqual(resyncs, [{ from_seq: 7, to_seq: 9, reason: 'retention', via: 'pull' }]);
+ assert.deepEqual(seen, [f.seq]);
+
+ // Pull needs credentials; realtime does not.
+ assert.throws(() => loadConfig({ OV_EVENTS_MODE: 'pull' }), /OV_CLIENT_ID/);
+
+ // ── realtime: anonymous, public events only, resume ────
+ const rtEnv = { OV_EVENTS_MODE: 'realtime', OV_TOPICS: 'media.object.*', OV_CURSOR_PATH: path.join(dir, 'realtime.json') };
+ const live = [];
+ const rt = createSubscriber(loadConfig(rtEnv), { fetch: platform.fetch, log: quiet, onEvent: (ev, { seq }) => { live.push({ seq, type: ev.event_type }); } });
+ const subscription = rt.start();
+ await waitFor(() => subscription.connected, 'the realtime connection');
+ const p1 = platform.publishEvent(media());
+ platform.publishEvent(media('internal')); // never reaches an anonymous viewer
+ const p2 = platform.publishEvent(media('public', 'media.object.deleted'));
+ await waitFor(() => live.length === 2, 'two public events');
+ assert.deepEqual(live.map((x) => x.seq), [p1.seq, p2.seq]);
+ await waitFor(() => createCursorStore(rtEnv.OV_CURSOR_PATH).get('realtime') === p2.seq, 'the realtime cursor');
+
+ // The connection drops; an event published meanwhile arrives once after the reconnect.
+ platform.dropRealtime();
+ const p3 = platform.publishEvent(media());
+ await waitFor(() => live.length === 3, 'the event published during the drop');
+ assert.equal(live[2].seq, p3.seq);
+ await new Promise((r) => setTimeout(r, 100));
+ assert.equal(live.length, 3, 'no duplicates after resuming');
+ rt.stop();
+
+ // A restart resumes from the saved cursor: the event published while stopped is delivered.
+ const p4 = platform.publishEvent(media());
+ const after = [];
+ const rt2 = createSubscriber(loadConfig(rtEnv), { fetch: platform.fetch, log: quiet, onEvent: (ev, { seq }) => { after.push(seq); } });
+ rt2.start();
+ await waitFor(() => after.length === 1, 'the event published while stopped');
+ assert.deepEqual(after, [p4.seq]);
+ rt2.stop();
+
+ // A cursor ahead of the stream (restored from another environment): a gap, then live events.
+ const aheadPath = path.join(dir, 'ahead.json');
+ createCursorStore(aheadPath).set('realtime', 9999);
+ const rtGaps = [];
+ const rt3 = createSubscriber(loadConfig({ ...rtEnv, OV_CURSOR_PATH: aheadPath }), { fetch: platform.fetch, log: quiet, onEvent() {}, onResync: (g, { via }) => rtGaps.push({ ...g, via }) });
+ rt3.start();
+ await waitFor(() => rtGaps.length === 1, 'the realtime gap');
+ assert.equal(rtGaps[0].reason, 'cursor_ahead');
+ assert.equal(rtGaps[0].via, 'realtime');
+ rt3.stop();
+
+ // No credentials were sent on the anonymous stream.
+ const streamCalls = platform.stats.requests.filter((r) => r.url.includes('/realtime/stream'));
+ assert.ok(streamCalls.length >= 3);
+ assert.ok(streamCalls.every((r) => !r.headers.authorization));
+
+ fs.rmSync(dir, { recursive: true, force: true });
+ console.log('event-subscriber: ok');
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+# A CONFIDENTIAL developer app holding media.object.upload (audience openvibe.media).
+OV_NETWORK_URL=https://openvibe.network
+OV_CLIENT_ID=
+OV_CLIENT_SECRET=
+# Your project id (prj_...): Network puts it in the token's ns claim, and Media stores under it.
+OV_MEDIA_NAMESPACE=
+# Optional: an explicit Media origin instead of the one the registry advertises.
+OV_MEDIA_URL=
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+# Media uploader
+
+Upload a file to OpenVibe.Media as a developer app, using `openvibe-sdk/media` and nothing else.
+
+```bash
+node --env-file=.env upload.js ./logo.png
+# {
+# "key": "3f2a9c0d1b7e-logo.png",
+# "public_url": "https://openvibe.media/f/3f2a9c0d1b7e-logo.png",
+# "size": 5120, "mime": "image/png", "sha256": "…", "deduplicated": false
+# }
+```
+
+## What it proves
+
+- An app with only a client id, a secret and one grant (`media.object.upload`) gets a 5-minute
+ app token from Network's public token endpoint, asking for exactly that capability (`scope`).
+- Media's origin comes from the platform descriptor (`client.discover()`), not a hard-coded host,
+ and the contracts version the network runs is checked against the SDK's supported range.
+- The upload lands in the app's own namespace. For a developer app that is the **project id**
+ (`prj_…`), because Network puts the project id in the token's `ns` claim. Another namespace is
+ refused by Media with `403 capability.namespace_denied`.
+- Uploads are content-addressed: the same bytes again come back `deduplicated`, so retries are safe.
+- The secret is read from the environment and never printed, not even in error messages.
+
+## Files
+
+| File | What |
+|---|---|
+| `upload.js` | `loadConfig()`, `createUploader()`, `explain()` (hints for the usual errors), CLI |
+| `test/smoke.test.js` | the flow against `openvibe-sdk/testing` (fake Network + Media) |
+
+## Run the smoke test
+
+```bash
+npm test # from this folder, or `npm test -- media` from the repository root
+```
+
+No network: Network and Media are the SDK's in-process mock, with real RS256 tokens and the same
+audience, capability and namespace checks.
+
+## Run it against the real platform
+
+1. Follow the [walkthrough](../../README.md#end-to-end-walkthrough) to create a project and a
+ **confidential** app, and request `media.object.upload` on it.
+2. `cp .env.example .env` and fill in `OV_CLIENT_ID`, `OV_CLIENT_SECRET` and
+ `OV_MEDIA_NAMESPACE` (your `prj_…` project id).
+3. `node --env-file=.env upload.js ./some-file.png`
+
+What has to be true on the platform side first, and is **not** true by default today:
+
+- **Sandbox apps get no Media token.** Network issues sandbox tokens only for audiences listed in
+ `DEV_SANDBOX_AUDIENCES`, which is empty by default, so a sandbox app gets `invalid_target`.
+ Media must also accept `env: sandbox` tokens. Until both opt in, use a production app (staff
+ switch the project to `sandbox+production`).
+- **The capability must be in your project's allowance** (staff set it; the default allowance is empty).
+- **Media needs a tenant named after your project id.** Media only accepts uploads for tenants it
+ knows, and tenants are created by the Media operators.
+- Media v1 accepts app tokens for **upload only**. Listing, reading metadata and deleting
+ (`media.files.list/get/delete`) need a Media app API key, which developer apps do not have.
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+{
+ "name": "openvibe-example-media-uploader",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Media uploader: upload a file to OpenVibe.Media with an app token.",
+ "license": "MIT",
+ "main": "upload.js",
+ "scripts": {
+ "start": "node --env-file-if-exists=.env upload.js",
+ "test": "node test/smoke.test.js"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
+'use strict';
+/**
+ * Smoke test: the uploader against openvibe-sdk/testing's mock platform (fake Network + Media at
+ * their public origins, real RS256 tokens, audience/capability/namespace checks). No network.
+ */
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { createMockPlatform } = require('openvibe-sdk/testing');
+const { createUploader, explain, loadConfig } = require('../upload');
+
+// CI has no network: anything that is not the mock platform fails loudly.
+globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
+
+const APP = 'app_01JEXAMPLEMEDIA0000000000';
+const PRJ = 'prj_01JEXAMPLEPROJECT00000000';
+const OTHER = 'prj_01JSOMEONEELSE00000000000';
+const SECRET = 'ovsec_media_uploader_test_secret';
+
+(async () => {
+ const platform = createMockPlatform({
+ clients: {
+ [APP]: { secret: SECRET, grants: [{ capability: 'media.object.upload', audience: 'openvibe.media', namespaces: [PRJ] }] },
+ app_01JNOGRANTS000000000000000: { secret: 'x', grants: [] },
+ },
+ mediaApps: { [PRJ]: {}, [OTHER]: {} },
+ });
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-'));
+ const file = path.join(dir, 'hello.txt');
+ fs.writeFileSync(file, 'hello from an OpenVibe example\n');
+
+ const env = { OV_CLIENT_ID: APP, OV_CLIENT_SECRET: SECRET, OV_MEDIA_NAMESPACE: PRJ };
+
+ // 1. Upload: token for openvibe.media, Media origin from discovery, file stored in the project namespace.
+ const uploader = createUploader(loadConfig(env), { fetch: platform.fetch });
+ const out = await uploader.upload(file);
+ assert.match(out.key, /^[0-9a-f]{12}-hello\.txt$/);
+ assert.equal(out.public_url, `https://openvibe.media/f/${out.key}`);
+ assert.equal(out.mime, 'text/plain');
+ assert.equal(out.sha256, require('node:crypto').createHash('sha256').update(fs.readFileSync(file)).digest('hex'));
+ assert.ok(platform.state.files.has(`${PRJ}|${out.key}`), 'stored under the project namespace');
+
+ // The upload carried a short-lived Network token (not an API key) and a trace.
+ const call = platform.stats.requests.find((r) => r.url === `https://openvibe.media/api/v1/${PRJ}/files`);
+ assert.ok(call, 'Media was called at the discovered origin');
+ assert.match(call.headers.authorization, /^Bearer [\w-]+\.[\w-]+\.[\w-]+$/);
+ assert.match(call.headers.traceparent, /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
+ const token = platform.stats.requests.find((r) => r.url.endsWith('/oauth/token'));
+ assert.ok(token);
+
+ // 2. Same bytes again: content-addressed, deduplicated, safe to retry.
+ const again = await uploader.upload(file);
+ assert.equal(again.key, out.key);
+ assert.equal(again.deduplicated, true);
+
+ // 3. Another project's namespace is refused by Media (the token's ns is this project only).
+ const wrongNs = createUploader(loadConfig({ ...env, OV_MEDIA_NAMESPACE: OTHER }), { fetch: platform.fetch });
+ await assert.rejects(wrongNs.upload(file), (err) => err.status === 403 && err.code === 'capability.namespace_denied');
+
+ // 4. No grant: Network issues no token for the audience.
+ const noGrant = createUploader(loadConfig({ ...env, OV_CLIENT_ID: 'app_01JNOGRANTS000000000000000', OV_CLIENT_SECRET: 'x' }), { fetch: platform.fetch });
+ await assert.rejects(noGrant.upload(file), (err) => err.code === 'invalid_scope' && /request it/i.test(explain(err)));
+
+ // 5. Wrong secret: invalid_client, and the secret never appears in the explanation.
+ const badSecret = createUploader(loadConfig({ ...env, OV_CLIENT_SECRET: 'wrong-secret-value' }), { fetch: platform.fetch });
+ await assert.rejects(badSecret.upload(file), (err) => err.code === 'invalid_client' && !explain(err).includes('wrong-secret-value'));
+
+ // 6. Missing configuration names the variables, never their values.
+ assert.throws(() => loadConfig({ OV_CLIENT_SECRET: SECRET }), (err) => err.code === 'config.missing' && /OV_CLIENT_ID/.test(err.message) && !err.message.includes(SECRET));
+
+ fs.rmSync(dir, { recursive: true, force: true });
+ console.log('media-uploader: ok');
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Media uploader: upload a file to OpenVibe.Media as a developer app.
+ *
+ * node --env-file=.env upload.js ./logo.png
+ *
+ * 1. Ask OpenVibe.Network for an app token (client credentials, audience openvibe.media, scope
+ * media.object.upload).
+ * 2. Find Media's origin in the platform descriptor (registry discovery), never a hard-coded host.
+ * 3. Upload to the Media namespace your grant names. For a developer app that is your project id
+ * (prj_…), because Network puts the project id in the token's `ns` claim.
+ *
+ * Only openvibe-sdk and the public token endpoint are used. The client secret is read from the
+ * environment and never printed.
+ */
+const fs = require('node:fs');
+const path = require('node:path');
+const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+const { createMediaClient } = require('openvibe-sdk/media');
+
+const TYPES = {
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
+ '.svg': 'image/svg+xml', '.txt': 'text/plain', '.json': 'application/json', '.pdf': 'application/pdf',
+ '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.webm': 'video/webm',
+};
+
+function loadConfig(env = process.env) {
+ const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET', 'OV_MEDIA_NAMESPACE'].filter((k) => !env[k]);
+ if (missing.length) {
+ const err = new Error(`missing environment variables: ${missing.join(', ')} (see .env.example)`);
+ err.code = 'config.missing';
+ throw err;
+ }
+ return {
+ network: env.OV_NETWORK_URL || 'https://openvibe.network',
+ clientId: env.OV_CLIENT_ID,
+ clientSecret: env.OV_CLIENT_SECRET,
+ namespace: env.OV_MEDIA_NAMESPACE,
+ mediaUrl: env.OV_MEDIA_URL || null,
+ };
+}
+
+function createUploader(config, { fetch } = {}) {
+ const tokens = createServiceTokenClient({
+ network: config.network,
+ clientId: config.clientId,
+ clientSecret: config.clientSecret,
+ scope: { 'openvibe.media': 'media.object.upload' }, // ask for exactly what this program uses
+ fetch,
+ });
+ const client = createClient({
+ network: config.network,
+ fetch,
+ tokenProvider: tokens,
+ baseUrls: config.mediaUrl ? { media: config.mediaUrl } : undefined,
+ });
+
+ return {
+ client,
+ /** Upload one file -> { key, url, public_url, size, mime, sha256, deduplicated? } */
+ async upload(filePath) {
+ const discovery = await client.discover(); // origins + contracts version check
+ const media = createMediaClient(client, { app: config.namespace, publicOrigin: await client.origin('media') });
+ const bytes = await fs.promises.readFile(filePath);
+ const filename = path.basename(filePath);
+ const contentType = TYPES[path.extname(filename).toLowerCase()] || 'application/octet-stream';
+ const file = await media.upload(bytes, { filename, contentType });
+ return { ...file, contracts: discovery.contractsVersion };
+ },
+ };
+}
+
+/** A human hint for the errors a new developer is most likely to meet. */
+function explain(err) {
+ const code = isOpenVibeError(err) ? err.code : err && err.code;
+ switch (code) {
+ case 'config.missing': return err.message;
+ case 'invalid_client': return 'Network refused the client id or secret. Check OV_CLIENT_ID / OV_CLIENT_SECRET (rotate the secret in your project if it was lost).';
+ case 'invalid_target': return 'Network does not issue tokens for openvibe.media to this app. Sandbox apps only get tokens for audiences that accept sandbox traffic; see the README.';
+ case 'invalid_scope': return 'The app holds no approved media.object.upload grant. Request it on your app and have a project admin approve it.';
+ case 'capability.namespace_denied': return 'Your token is not valid for this namespace. OV_MEDIA_NAMESPACE must be your project id (prj_...).';
+ case 'token.sandbox_refused': return 'Media does not accept sandbox tokens yet. See the README section "Running against the real platform".';
+ default: return isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''}${err.requestId ? ` (request ${err.requestId})` : ''}` : String(err && err.message);
+ }
+}
+
+async function main(argv = process.argv.slice(2)) {
+ const file = argv[0];
+ if (!file) {
+ console.error('usage: node upload.js <file>');
+ return 2;
+ }
+ try {
+ const uploader = createUploader(loadConfig());
+ const out = await uploader.upload(file);
+ console.log(JSON.stringify({ key: out.key, public_url: out.public_url, size: out.size, mime: out.mime, sha256: out.sha256, deduplicated: Boolean(out.deduplicated) }, null, 2));
+ return 0;
+ } catch (err) {
+ console.error(`upload failed: ${explain(err)}`);
+ return 1;
+ }
+}
+
+if (require.main === module) main().then((code) => { process.exitCode = code; });
+
+module.exports = { loadConfig, createUploader, explain, main };
new file mode 100644
new file mode 100644
@@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
+# Only for "node validate.js mod.json --registry" (the public registry needs no credentials).
+OV_NETWORK_URL=https://openvibe.network
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+# Mod manifest
+
+Write a `mods.mod-manifest@1` document and check it before you publish, with openvibe-contracts.
+
+```bash
+node validate.js mod.json # schema + catalog + compatibility, offline
+node validate.js mod.json --registry # also asks the network's public registry about each capability
+node validate.js --new-id # a fresh mod_<ULID> for a draft
+```
+
+`mod.json` is a small valid manifest: a `games.browser` mod on the `games-content@1` runtime that
+asks for `games.world.announce` and `games.prop.place`.
+
+## What it proves
+
+- **Schema**: `contracts.validate('mods.mod-manifest@1', manifest)` (ADR-013), with JSON-pointer paths.
+- **Catalog**: every requested capability exists in the openvibe-contracts catalog, is `public`
+ (`first-party` and `internal` capabilities are never granted to mods or third-party apps, so
+ asking for one can only fail at install time) and `active` (a `planned` one is a warning).
+- **Publisher** is a user or an app.
+- **Compatibility**: the runtime's major version is inside `compatibility.runtime`; a
+ `compatibility.contracts` range that excludes the contracts release you validated with is a warning.
+- **Registry** (optional): each capability as the live network's registry knows it, with
+ `openvibe-sdk/registry` and no credentials.
+
+A manifest only **requests** capabilities. What a mod may do is the approved subset of its
+install's grants; trust tiers are metadata and never change a grant check.
+
+## Files
+
+| File | What |
+|---|---|
+| `mod.json` | the example manifest |
+| `validate.js` | `validateManifest()`, `checkRegistry()`, CLI |
+| `test/smoke.test.js` | the example validates; each class of mistake is caught at its path |
+
+## Run the smoke test
+
+```bash
+npm test
+```
+
+## Run it against the real platform
+
+Validation is offline and works today: `node validate.js mod.json`. The `--registry` check reads
+`https://openvibe.network/api/v1/registry/capabilities/<id>` (public, no credentials).
+
+Publishing is not public yet: registering a mod and managing installs (`games.mod.manage`) is a
+`first-party` capability, install grants (`mods.grant.manage`) are `planned`, and the manifest
+editor and release flow belong to OpenVibe.Codes, which has not launched. Mod ids are assigned
+by that flow; `--new-id` only gives a draft a well-formed id.
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+{
+ "id": "mod_01M366EZ0WM23BN1VW7J3WVWRR",
+ "name": "Welcome Sign",
+ "version": "1.0.0",
+ "description": "Places a welcome sign in the town square and greets players when a world opens.",
+ "publisher": { "type": "app", "id": "app_01M366EZ7F3S8VA3MZ3Y7ENCTJ" },
+ "target": "games.browser",
+ "runtime": "games-content@1",
+ "permissions": {
+ "capabilities": ["games.world.announce", "games.prop.place"]
+ },
+ "resources": { "cpuMs": 2, "memoryMb": 16, "storageMb": 1 },
+ "compatibility": { "runtime": ">=1.0.0 <2.0.0", "contracts": ">=0.26.0 <1.0.0" },
+ "homepage": "https://github.com/OpenVibers/OpenVibe.Examples/tree/main/examples/mod-manifest"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "name": "openvibe-example-mod-manifest",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Mod manifest: validate a mods.mod-manifest@1 document with openvibe-contracts.",
+ "license": "MIT",
+ "main": "validate.js",
+ "scripts": {
+ "start": "node --env-file-if-exists=.env validate.js",
+ "test": "node test/smoke.test.js"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+'use strict';
+/** Smoke test: the example manifest validates; each class of mistake is caught with its path. */
+const assert = require('node:assert/strict');
+const { createMockPlatform } = require('openvibe-sdk/testing');
+const { validateManifest, checkRegistry, main } = require('../validate');
+const example = require('../mod.json');
+
+globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
+
+const clone = () => JSON.parse(JSON.stringify(example));
+const paths = (r) => r.errors.map((e) => e.path);
+
+(async () => {
+ // The shipped example is valid, with no warnings.
+ const ok = validateManifest(example);
+ assert.deepEqual(ok, { valid: true, errors: [], warnings: [] });
+
+ // Schema errors (openvibe-contracts): missing permissions, a two-segment capability id, a bad id.
+ let m = clone(); delete m.permissions;
+ assert.equal(validateManifest(m).valid, false);
+ assert.ok(validateManifest(m).errors.some((e) => /permissions/.test(e.message)));
+ m = clone(); m.permissions.capabilities = ['games.announce'];
+ assert.deepEqual(paths(validateManifest(m)), ['/permissions/capabilities/0']);
+ m = clone(); m.id = 'mod_not-a-ulid';
+ assert.deepEqual(paths(validateManifest(m)), ['/id']);
+
+ // Catalog errors: unknown, internal and first-party capabilities are never grantable to a mod.
+ m = clone(); m.permissions.capabilities = ['games.world.announce', 'games.world.teleport', 'events.event.publish', 'games.mod.manage'];
+ const cat = validateManifest(m);
+ assert.deepEqual(paths(cat), ['/permissions/capabilities/1', '/permissions/capabilities/2', '/permissions/capabilities/3']);
+ assert.match(cat.errors[0].message, /not in the openvibe-contracts/);
+ assert.match(cat.errors[1].message, /is internal:/);
+ assert.match(cat.errors[2].message, /first-party/);
+
+ // A planned (not yet active) public capability is a warning, not an error.
+ m = clone(); m.permissions.capabilities = ['network.project.manage'];
+ const planned = validateManifest(m);
+ assert.equal(planned.valid, true);
+ assert.match(planned.warnings[0].message, /planned/);
+
+ // Publisher must be a user or an app.
+ m = clone(); m.publisher = { type: 'service', id: 'live' };
+ assert.deepEqual(paths(validateManifest(m)), ['/publisher/type']);
+
+ // Compatibility: the runtime major must be inside compatibility.runtime.
+ m = clone(); m.runtime = 'games-content@2';
+ assert.deepEqual(paths(validateManifest(m)), ['/compatibility/runtime']);
+ m = clone(); m.compatibility.runtime = 'whenever';
+ assert.deepEqual(paths(validateManifest(m)), ['/compatibility/runtime']);
+ m = clone(); m.compatibility.contracts = '>=1.0.0';
+ const future = validateManifest(m);
+ assert.equal(future.valid, true);
+ assert.match(future.warnings[0].message, /outside >=1\.0\.0/);
+
+ // Outbound hosts are allowed but flagged for review.
+ m = clone(); m.resources.outboundHosts = ['api.example.com'];
+ assert.match(validateManifest(m).warnings[0].message, /reviewed/);
+
+ // The optional registry check uses the public registry (no token); here, the SDK's mock.
+ const platform = createMockPlatform({ capabilities: [{ id: 'games.world.announce', owner: 'games', visibility: 'public', status: 'active' }] });
+ const reg = await checkRegistry(example, { fetch: platform.fetch });
+ assert.deepEqual(reg, [
+ { id: 'games.world.announce', registered: true, visibility: 'public', status: 'active' },
+ { id: 'games.prop.place', registered: false, visibility: null, status: null },
+ ]);
+ assert.ok(platform.stats.requests.every((r) => !r.headers.authorization), 'the registry needs no credentials');
+
+ // CLI exit codes.
+ const out = console.log;
+ const err = console.error;
+ console.log = () => {};
+ console.error = () => {};
+ try {
+ assert.equal(await main([require.resolve('../mod.json')]), 0);
+ assert.equal(await main([]), 2);
+ assert.equal(await main(['/nonexistent/mod.json']), 2);
+ } finally { console.log = out; console.error = err; }
+
+ console.log('mod-manifest: ok');
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Mod manifest: check a mod manifest before you publish it.
+ *
+ * node validate.js mod.json # schema + catalog checks, offline
+ * node validate.js mod.json --registry # also ask the network's registry about each capability
+ * node validate.js --new-id # a fresh mod_<ULID> for a draft manifest
+ *
+ * 1. Schema: openvibe-contracts validate('mods.mod-manifest@1', manifest) (ADR-013).
+ * 2. Catalog: every requested capability must exist in the contracts catalog, be `active`, and have
+ * visibility `public`. first-party and internal capabilities are never granted to mods or apps,
+ * so asking for one can only fail at install time; this says so now.
+ * 3. Compatibility: the `runtime` major must fall inside compatibility.runtime, and
+ * compatibility.contracts (if given) should include the contracts release you validated with.
+ * 4. With --registry: openvibe-sdk/registry capability(id) on the live network (public, no token).
+ *
+ * The manifest only REQUESTS capabilities. What a mod may actually do is the approved subset of an
+ * install's grants; trust tiers are metadata and never change a grant check.
+ */
+const fs = require('node:fs');
+const contracts = require('openvibe-contracts');
+const { createClient, satisfiesRange } = require('openvibe-sdk/core');
+const { createRegistryClient } = require('openvibe-sdk/registry');
+
+const CONTRACTS_VERSION = require('openvibe-contracts/package.json').version;
+const PUBLISHER_TYPES = new Set(['user', 'app']);
+
+/** -> { valid, errors: [{ path, message }], warnings: [{ path, message }] } */
+function validateManifest(manifest, { catalog = contracts.capabilities, contractsVersion = CONTRACTS_VERSION } = {}) {
+ const errors = [];
+ const warnings = [];
+ const schema = contracts.validate('mods.mod-manifest@1', manifest);
+ if (!schema.valid) {
+ for (const e of schema.errors) errors.push({ path: e.path || '/', message: e.message });
+ return { valid: false, errors, warnings };
+ }
+
+ if (!PUBLISHER_TYPES.has(manifest.publisher.type)) {
+ errors.push({ path: '/publisher/type', message: `a mod is published by a user or an app, not a ${manifest.publisher.type}` });
+ }
+
+ manifest.permissions.capabilities.forEach((id, i) => {
+ const p = `/permissions/capabilities/${i}`;
+ const cap = catalog.get(id);
+ if (!cap) return errors.push({ path: p, message: `${id} is not in the openvibe-contracts ${contractsVersion} catalog` });
+ if (cap.visibility !== 'public') return errors.push({ path: p, message: `${id} is ${cap.visibility}: it is never granted to mods or third-party apps` });
+ if (cap.status !== 'active') warnings.push({ path: p, message: `${id} is ${cap.status}, not active yet` });
+ return undefined;
+ });
+ if (!manifest.permissions.capabilities.length) warnings.push({ path: '/permissions/capabilities', message: 'the mod asks for no capabilities' });
+
+ const major = Number(manifest.runtime.split('@')[1]);
+ try {
+ if (!satisfiesRange(`${major}.0.0`, manifest.compatibility.runtime)) {
+ errors.push({ path: '/compatibility/runtime', message: `runtime ${manifest.runtime} is outside ${manifest.compatibility.runtime}` });
+ }
+ } catch {
+ errors.push({ path: '/compatibility/runtime', message: `not a semver range: ${manifest.compatibility.runtime}` });
+ }
+ if (manifest.compatibility.contracts) {
+ try {
+ if (!satisfiesRange(contractsVersion, manifest.compatibility.contracts)) {
+ warnings.push({ path: '/compatibility/contracts', message: `validated with openvibe-contracts ${contractsVersion}, outside ${manifest.compatibility.contracts}` });
+ }
+ } catch {
+ errors.push({ path: '/compatibility/contracts', message: `not a semver range: ${manifest.compatibility.contracts}` });
+ }
+ }
+ if ((manifest.resources.outboundHosts || []).length) {
+ warnings.push({ path: '/resources/outboundHosts', message: 'outbound network access is reviewed before a mod is installed' });
+ }
+ return { valid: errors.length === 0, errors, warnings };
+}
+
+/** Ask the network registry whether each requested capability is registered there. */
+async function checkRegistry(manifest, { network = process.env.OV_NETWORK_URL || 'https://openvibe.network', fetch } = {}) {
+ const registry = createRegistryClient(createClient({ network, fetch }));
+ const out = [];
+ for (const id of manifest.permissions.capabilities) {
+ const cap = await registry.capability(id);
+ out.push({ id, registered: Boolean(cap), visibility: cap ? cap.visibility || null : null, status: cap ? cap.status || null : null });
+ }
+ return out;
+}
+
+async function main(argv = process.argv.slice(2)) {
+ if (argv.includes('--new-id')) {
+ console.log(contracts.ids.newId('mod'));
+ return 0;
+ }
+ const file = argv.find((a) => !a.startsWith('--'));
+ if (!file) { console.error('usage: node validate.js <manifest.json> [--registry] | --new-id'); return 2; }
+ let manifest;
+ try { manifest = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (err) {
+ console.error(`cannot read ${file}: ${err.message}`);
+ return 2;
+ }
+ const result = validateManifest(manifest);
+ for (const e of result.errors) console.log(`error ${e.path}: ${e.message}`);
+ for (const w of result.warnings) console.log(`warning ${w.path}: ${w.message}`);
+ if (result.valid && argv.includes('--registry')) {
+ try {
+ for (const c of await checkRegistry(manifest)) {
+ console.log(`registry ${c.id}: ${c.registered ? `${c.visibility}, ${c.status}` : 'not registered on this network'}`);
+ }
+ } catch (err) {
+ console.log(`registry check failed: ${err.code || err.message}`);
+ }
+ }
+ console.log(result.valid ? `${file}: valid mods.mod-manifest@1 (openvibe-contracts ${CONTRACTS_VERSION})` : `${file}: invalid`);
+ return result.valid ? 0 : 1;
+}
+
+if (require.main === module) main().then((code) => { process.exitCode = code; });
+
+module.exports = { validateManifest, checkRegistry, main, CONTRACTS_VERSION };
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+# A CONFIDENTIAL developer app in your OpenVibe project (client id app_..., secret ovsec_... shown once).
+OV_NETWORK_URL=https://openvibe.network
+OV_CLIENT_ID=
+OV_CLIENT_SECRET=
+# Audiences to ask for app tokens (comma-separated); /status shows the capabilities each token carries.
+OV_AUDIENCES=openvibe.media,openvibe.tools
+OV_PORT=3002
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+# Node server app
+
+A small HTTP server that runs as a developer app: it authenticates with **client credentials**
+and finds the platform through the **registry**.
+
+```bash
+node --env-file=.env server.js
+curl -s localhost:3002/status
+```
+
+`GET /status` answers with:
+
+- `contracts`: the openvibe-contracts release the network runs, and whether this SDK supports it;
+- `services`: every registered service with its status and origin (from the registry);
+- `grants`: for each audience in `OV_AUDIENCES`, the capabilities an app token for it carries
+ (approved grants within your project's allowance), each described by the registry, plus the
+ token's `project_id`, `env` and `ns`; or why Network refused (`invalid_scope`, `invalid_target`).
+
+## What it proves
+
+- Discovery with no credentials: `client.discover()` reads `https://openvibe.network/.well-known/openvibe`
+ and `openvibe-sdk/registry` reads `/api/v1/registry/*`. Both are public.
+- One cached app token per audience (`createServiceTokenClient` as the client's `tokenProvider`),
+ refreshed shortly before its 5-minute expiry. A second `/status` does not ask Network again.
+- The incoming request's `traceparent` continues on every outbound call (`client.fromRequest(req)`).
+- Errors are reported by stable code and request id; the secret and tokens never leave the process.
+
+## Files
+
+| File | What |
+|---|---|
+| `server.js` | `loadConfig()`, `createApp()` (the HTTP server and `status()`), `peekClaims()` |
+| `test/smoke.test.js` | discovery, registry, grants per audience, token caching, trace propagation |
+
+## Run the smoke test
+
+```bash
+npm test
+```
+
+## Run it against the real platform
+
+1. Create a project and a **confidential** app ([walkthrough](../../README.md#end-to-end-walkthrough)).
+2. `cp .env.example .env`, fill in `OV_CLIENT_ID` and `OV_CLIENT_SECRET`, set `OV_AUDIENCES` to
+ the audiences you hold grants for (`openvibe.media`, `openvibe.tools`, …).
+3. `node --env-file=.env server.js` and open `http://localhost:3002/status`.
+
+Discovery and the registry work for anyone today. The `grants` part shows a token only when
+Network will issue one: a **sandbox** app gets `invalid_target` for every audience until staff list
+that audience in `DEV_SANDBOX_AUDIENCES` (empty by default), and any app gets `invalid_scope` until
+a grant is approved inside the project's allowance (also empty by default).
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+{
+ "name": "openvibe-example-node-server-app",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Node server app: client-credentials app token and registry discovery.",
+ "license": "MIT",
+ "main": "server.js",
+ "scripts": {
+ "start": "node --env-file-if-exists=.env server.js",
+ "test": "node test/smoke.test.js"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Node server app: an app principal that authenticates with client credentials and finds the
+ * platform through the registry.
+ *
+ * node --env-file=.env server.js # http://localhost:3002/status
+ *
+ * GET /status answers:
+ * - the platform descriptor (Network's /.well-known/openvibe): service origins and the contracts
+ * release the network runs, checked against the range this SDK supports;
+ * - the registry's services;
+ * - for each audience in OV_AUDIENCES, the capabilities an app token for that audience carries
+ * (approved grants within the project's allowance), described by the registry.
+ *
+ * The app token is requested from the public token endpoint and cached per audience until shortly
+ * before it expires (5-minute lifetime). The client secret is never printed or returned.
+ */
+const http = require('node:http');
+const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+const { createRegistryClient } = require('openvibe-sdk/registry');
+
+function loadConfig(env = process.env) {
+ const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET'].filter((k) => !env[k]);
+ if (missing.length) {
+ const err = new Error(`missing environment variables: ${missing.join(', ')} (see .env.example)`);
+ err.code = 'config.missing';
+ throw err;
+ }
+ return {
+ network: env.OV_NETWORK_URL || 'https://openvibe.network',
+ clientId: env.OV_CLIENT_ID,
+ clientSecret: env.OV_CLIENT_SECRET,
+ audiences: String(env.OV_AUDIENCES || 'openvibe.media').split(',').map((s) => s.trim()).filter(Boolean),
+ port: Number(env.OV_PORT || 3002),
+ };
+}
+
+/**
+ * The claims of a token this process just received from Network over TLS. Only read for display;
+ * the services that receive the token are the ones that verify it.
+ */
+function peekClaims(token) {
+ try { return JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8')); } catch { return {}; }
+}
+
+function createApp(config, { fetch, log = console } = {}) {
+ const tokens = createServiceTokenClient({ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch });
+ const client = createClient({ network: config.network, fetch, tokenProvider: tokens });
+
+ /** What an app token for `audience` carries right now, or the reason there is none. */
+ async function grantsFor(audience, registry) {
+ let token;
+ try {
+ token = await tokens.getToken({ audience });
+ } catch (err) {
+ if (isOpenVibeError(err) && ['invalid_scope', 'invalid_target', 'unauthorized_client'].includes(err.code)) {
+ return { audience, capabilities: [], refused: err.code, detail: err.detail || null };
+ }
+ throw err;
+ }
+ const claims = peekClaims(token);
+ const capabilities = await Promise.all((claims.cap || []).map(async (id) => {
+ const c = await registry.capability(id);
+ return { id, known: Boolean(c), visibility: c ? c.visibility || null : null, description: c ? c.description || null : null };
+ }));
+ return {
+ audience,
+ capabilities,
+ subject: claims.sub || null,
+ project_id: claims.project_id || null,
+ env: claims.env || null,
+ namespaces: claims.ns || [],
+ expires_at: claims.exp ? new Date(claims.exp * 1000).toISOString() : null,
+ };
+ }
+
+ async function status(req) {
+ // Continue the caller's trace on every outbound call (traceparent + X-OpenVibe-Request-Id).
+ const scoped = req ? client.fromRequest(req) : client;
+ const registry = createRegistryClient(scoped);
+ const d = await scoped.discover();
+ const services = await registry.services();
+ return {
+ app: config.clientId,
+ network: config.network,
+ contracts: { version: d.contractsVersion, supported_range: d.contractsRange, compatible: d.compatible },
+ services: services.map((s) => ({ id: s.id, status: s.status, origin: d.origins[s.id] || s.publicOrigin || null })),
+ grants: await Promise.all(config.audiences.map((a) => grantsFor(a, registry))),
+ checked_at: new Date().toISOString(),
+ };
+ }
+
+ const server = http.createServer(async (req, res) => {
+ const send = (code, body) => {
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
+ res.end(JSON.stringify(body, null, 2));
+ };
+ const url = new URL(req.url, 'http://app.invalid');
+ try {
+ if (req.method === 'GET' && url.pathname === '/healthz') return send(200, { ok: true });
+ if (req.method === 'GET' && url.pathname === '/status') return send(200, await status(req));
+ return send(404, { error: 'not_found' });
+ } catch (err) {
+ // Log the stable code and request id, never tokens or secrets.
+ log.error(`[status] ${isOpenVibeError(err) ? `${err.code} request=${err.requestId}` : err.message}`);
+ return send(502, { error: isOpenVibeError(err) ? err.code : 'upstream_error', request_id: isOpenVibeError(err) ? err.requestId : null });
+ }
+ });
+
+ return { server, client, tokens, status };
+}
+
+if (require.main === module) {
+ let config;
+ try { config = loadConfig(); } catch (err) { console.error(err.message); process.exit(2); }
+ const { server } = createApp(config);
+ server.listen(config.port, () => console.log(`node-server-app: http://localhost:${config.port}/status (app ${config.clientId})`));
+}
+
+module.exports = { loadConfig, createApp, peekClaims };
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+/** Smoke test: client-credentials app token + registry discovery against the SDK's mock platform. */
+const assert = require('node:assert/strict');
+const http = require('node:http');
+const { createMockPlatform } = require('openvibe-sdk/testing');
+const { createApp, loadConfig } = require('../server');
+
+const realFetch = globalThis.fetch;
+globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
+
+const APP = 'app_01JEXAMPLESERVER000000000';
+const PRJ = 'prj_01JEXAMPLEPROJECT00000000';
+const SECRET = 'ovsec_node_server_test_secret';
+
+function get(port, path, headers = {}) {
+ return new Promise((resolve, reject) => {
+ http.get({ host: '127.0.0.1', port, path, headers }, (res) => {
+ let body = '';
+ res.on('data', (c) => { body += c; });
+ res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(body) }));
+ }).on('error', reject);
+ });
+}
+
+(async () => {
+ const platform = createMockPlatform({
+ clients: { [APP]: { secret: SECRET, grants: [{ capability: 'media.object.upload', audience: 'openvibe.media', namespaces: [PRJ] }] } },
+ capabilities: [{ id: 'media.object.upload', owner: 'media', visibility: 'public', status: 'active', description: 'Upload an object into a granted namespace' }],
+ contractsVersion: '0.26.0',
+ });
+ const logs = [];
+ const log = { error: (m) => logs.push(m), log: (m) => logs.push(m) };
+ const app = createApp(loadConfig({ OV_CLIENT_ID: APP, OV_CLIENT_SECRET: SECRET, OV_AUDIENCES: 'openvibe.media,openvibe.tools' }), { fetch: platform.fetch, log });
+ await new Promise((r) => app.server.listen(0, '127.0.0.1', r));
+ const { port } = app.server.address();
+
+ const traceparent = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01';
+ const res = await get(port, '/status', { traceparent });
+ assert.equal(res.status, 200);
+ const s = res.body;
+
+ // Discovery: origins and the contracts version check.
+ assert.equal(s.contracts.version, '0.26.0');
+ assert.equal(s.contracts.compatible, true);
+ const media = s.services.find((x) => x.id === 'media');
+ assert.equal(media.origin, 'https://openvibe.media');
+
+ // The token for openvibe.media carries the approved grant, described by the registry.
+ const mediaGrants = s.grants.find((g) => g.audience === 'openvibe.media');
+ assert.deepEqual(mediaGrants.capabilities.map((c) => c.id), ['media.object.upload']);
+ assert.equal(mediaGrants.capabilities[0].visibility, 'public');
+ assert.deepEqual(mediaGrants.namespaces, [PRJ]);
+
+ // No grant for openvibe.tools: reported, not thrown.
+ const toolsGrants = s.grants.find((g) => g.audience === 'openvibe.tools');
+ assert.equal(toolsGrants.refused, 'invalid_scope');
+ assert.deepEqual(toolsGrants.capabilities, []);
+
+ // The incoming trace continues on the outbound registry calls.
+ const registryCall = platform.stats.requests.find((r) => r.url.includes('/api/v1/registry/services'));
+ assert.ok(registryCall.headers.traceparent.startsWith('00-0af7651916cd43dd8448eb211c80319c-'));
+
+ // Tokens are cached per audience: a second /status does not ask Network for a new media token.
+ const before = platform.stats.tokenRequests;
+ await get(port, '/status');
+ assert.equal(platform.stats.tokenRequests - before, 1, 'only the refused audience is asked again');
+
+ // Nothing secret leaves the process.
+ assert.ok(!JSON.stringify(s).includes(SECRET));
+ assert.ok(!logs.join('\n').includes(SECRET));
+
+ // Health.
+ assert.deepEqual((await get(port, '/healthz')).body, { ok: true });
+
+ app.server.close();
+ globalThis.fetch = realFetch;
+ console.log('node-server-app: ok');
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+# A CONFIDENTIAL developer app with the redirect URI below registered exactly as written.
+OV_NETWORK_URL=https://openvibe.network
+OV_CLIENT_ID=
+OV_CLIENT_SECRET=
+# Sign-in asks for capabilities of ONE audience; the app needs an approved grant for each.
+OV_AUDIENCE=openvibe.media
+OV_SCOPE=media.object.upload media.object.read
+OV_REDIRECT_URI=http://localhost:3009/callback
+# Defaults to the redirect URI's port.
+OV_PORT=3009
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+# OAuth app
+
+"Sign in with OpenVibe" for a **confidential** developer app: authorization code + PKCE, with the
+code exchanged on the server.
+
+```bash
+node --env-file=.env server.js # open http://localhost:3009 and click "Sign in with OpenVibe"
+```
+
+## What it proves
+
+| Step | Where | What |
+|---|---|---|
+| `/login` | server | `startAuthorization()` makes the PKCE pair (S256) and `state`; both stay in this browser's **server-side** session (HttpOnly, SameSite=Lax cookie); redirect to Network's `/oauth/authorize` with the capability ids to ask for (`scope`) |
+| Network | | the person signs in and chooses to continue; Network redirects back with `code` and `state` |
+| `/callback` | server | `readCallback()` checks `state` (refuses callbacks this browser did not start, and `error=` answers); the code is exchanged **once**, with the client secret, the PKCE verifier and the `audience`; the token is verified offline against Network's JWKS (signature, issuer, audience, expiry); the session id is replaced (no session fixation) |
+| `/api/me` | server | who signed in (`usr_…`), and the capabilities this app may use for them |
+
+What comes back is an **app token acting for the person**: `actor_type: app`, `sub: app:<your app id>`,
+`on_behalf_of: usr_…`, `cap` = what they authorized, `aud` = the one audience, 5-minute lifetime
+and **no refresh token**. When it expires the person signs in again. The app checks the token was
+minted for itself (`sub`) before trusting `on_behalf_of`.
+
+Also covered by the test: a replayed callback, a forged `state`, a declined consent
+(`oauth.access_denied`), a code bound to another PKCE challenge (`invalid_grant`), sign-out, and
+that neither the secret nor any token reaches the browser or the logs.
+
+## Files
+
+| File | What |
+|---|---|
+| `server.js` | `createApp()`, `exchangeAppCode()`, `signedInSubject()` |
+| `test/smoke.test.js` | the flow against `openvibe-sdk/testing` (`platform.authorize()` stands in for the consent page) |
+
+## Run the smoke test
+
+```bash
+npm test
+```
+
+## Run it against the real platform
+
+1. Create a project and a **confidential** app with the redirect URI
+ `http://localhost:3009/callback` ([walkthrough](../../README.md#end-to-end-walkthrough)).
+ `localhost` redirect URIs are allowed for sandbox apps only; a production app needs an https one.
+2. Request a grant for every capability in `OV_SCOPE` (for example `media.object.read` for audience
+ `openvibe.media`). Signing in needs at least one approved grant for `OV_AUDIENCE`.
+3. `cp .env.example .env`, fill in `OV_CLIENT_ID`, `OV_CLIENT_SECRET`, `OV_AUDIENCE`, `OV_SCOPE`.
+4. `node --env-file=.env server.js` and sign in **as a member of the project** (a sandbox app can
+ only be authorized by its project's members).
+
+What blocks it today: Network mints the token only if the app may have one for `OV_AUDIENCE`. A
+sandbox app gets `invalid_target` at the exchange until staff list that audience in
+`DEV_SANDBOX_AUDIENCES` (empty by default). A production app avoids that but needs an https
+redirect URI, so it has to run somewhere with TLS. Network's account chooser does not list the
+requested capabilities yet (no consent screen).
+
+## Why not `exchangeCode()`?
+
+`openvibe-sdk` 0.2.2's `exchangeCode()` requires a client secret and cannot send `audience`, which
+Network requires when the client is a developer app. `exchangeAppCode()` here calls the same
+public token endpoint through the SDK's core client (with retries off: a code works once).
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+{
+ "name": "openvibe-example-oauth-app",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OAuth app: server-side authorization code + PKCE exchange with OpenVibe.Network.",
+ "license": "MIT",
+ "main": "server.js",
+ "scripts": {
+ "start": "node --env-file-if-exists=.env server.js",
+ "test": "node test/smoke.test.js"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,207 @@
@@ -0,0 +1,207 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * OAuth app: "Sign in with OpenVibe" for a confidential developer app, done on the server.
+ *
+ * node --env-file=.env server.js # http://localhost:3009
+ *
+ * /login make a PKCE pair and a state (openvibe-sdk/auth), keep them in this browser's
+ * server-side session, redirect to Network's /oauth/authorize.
+ * /callback check the state (readCallback), exchange the code on the server with the client
+ * secret AND the PKCE verifier, verify the token offline against Network's JWKS, then
+ * start a fresh session (new session id: no session fixation).
+ * /api/me who signed in, and what this app may do on their behalf.
+ *
+ * What comes back is an APP token acting for the person (actor_type app, on_behalf_of usr_…,
+ * cap = the capabilities they authorized, 5-minute lifetime, no refresh token). When it expires
+ * the person signs in again. The secret, the verifier and tokens never reach the browser or logs.
+ */
+const crypto = require('node:crypto');
+const http = require('node:http');
+const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
+const { startAuthorization, readCallback, verifyUserToken } = require('openvibe-sdk/auth');
+
+const SESSION_COOKIE = 'ov_example_sid';
+const PENDING_TTL_MS = 10 * 60 * 1000;
+
+function loadConfig(env = process.env) {
+ const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET', 'OV_AUDIENCE', 'OV_SCOPE'].filter((k) => !env[k]);
+ if (missing.length) {
+ throw Object.assign(new Error(`missing environment variables: ${missing.join(', ')} (see .env.example)`), { code: 'config.missing' });
+ }
+ const redirectUri = env.OV_REDIRECT_URI || 'http://localhost:3009/callback';
+ return {
+ network: env.OV_NETWORK_URL || 'https://openvibe.network',
+ clientId: env.OV_CLIENT_ID,
+ clientSecret: env.OV_CLIENT_SECRET,
+ redirectUri,
+ audience: env.OV_AUDIENCE,
+ scope: env.OV_SCOPE.split(/[\s,]+/).filter(Boolean),
+ port: Number(env.OV_PORT || new URL(redirectUri).port || 3009),
+ secureCookie: redirectUri.startsWith('https://'),
+ };
+}
+
+/**
+ * POST <network>/oauth/token (authorization_code) for a developer app. openvibe-sdk 0.2.2's
+ * exchangeCode() cannot send `audience`, which Network requires for app clients, so this calls
+ * the public token endpoint through the SDK's core client. No retries: a code works once.
+ */
+async function exchangeAppCode(client, { clientId, clientSecret, code, codeVerifier, redirectUri, audience, scope }) {
+ return client.json({
+ service: 'network', path: '/oauth/token', method: 'POST', auth: false, retries: 0, idempotencyKey: false,
+ urlencoded: {
+ grant_type: 'authorization_code', code, redirect_uri: redirectUri, code_verifier: codeVerifier,
+ client_id: clientId, client_secret: clientSecret || undefined, audience, scope: scope && scope.length ? scope.join(' ') : undefined,
+ },
+ });
+}
+
+/** Who signed in, from verified claims: an app token names them in on_behalf_of. */
+function signedInSubject(claims, clientId) {
+ if (claims.actor_type === 'app') {
+ if (claims.sub !== `app:${clientId}`) return null; // a token minted for some other app
+ return claims.on_behalf_of || null;
+ }
+ return claims.subject_id || null; // a plain user token
+}
+
+const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
+
+function parseCookies(header) {
+ const out = {};
+ for (const part of String(header || '').split(';')) {
+ const i = part.indexOf('=');
+ if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
+ }
+ return out;
+}
+
+function createApp(config, { fetch, log = console } = {}) {
+ const client = createClient({ network: config.network, fetch });
+ const sessions = new Map(); // sid -> { pending?, auth? } (use a real store in production)
+
+ const newSid = () => crypto.randomBytes(32).toString('base64url');
+ const cookie = (sid, maxAge) => `${SESSION_COOKIE}=${sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${config.secureCookie ? '; Secure' : ''}`;
+ function sessionOf(req) {
+ const sid = parseCookies(req.headers.cookie)[SESSION_COOKIE];
+ return sid && sessions.has(sid) ? { sid, s: sessions.get(sid) } : null;
+ }
+ function current(req) {
+ const found = sessionOf(req);
+ if (!found || !found.s.auth) return null;
+ if (found.s.auth.expiresAt <= Date.now()) { found.s.auth = null; return null; }
+ return found.s.auth;
+ }
+
+ /** Forget abandoned sign-ins and expired sessions (an in-memory store has to clean up after itself). */
+ function prune() {
+ const now = Date.now();
+ for (const [sid, s] of sessions) {
+ const pendingDead = !s.pending || now - s.pending.at > PENDING_TTL_MS;
+ const authDead = !s.auth || s.auth.expiresAt <= now;
+ if (pendingDead && authDead) sessions.delete(sid);
+ }
+ }
+
+ async function login(req, res) {
+ prune();
+ const found = sessionOf(req);
+ const sid = found ? found.sid : newSid();
+ const { url, state, codeVerifier } = await startAuthorization({
+ network: config.network, clientId: config.clientId, redirectUri: config.redirectUri, scope: config.scope,
+ });
+ sessions.set(sid, { ...(found ? found.s : {}), pending: { state, codeVerifier, at: Date.now() } });
+ res.writeHead(302, { Location: url, 'Set-Cookie': cookie(sid, 3600), 'Cache-Control': 'no-store' });
+ res.end();
+ }
+
+ async function callback(req, res) {
+ const found = sessionOf(req);
+ const pending = found && found.s.pending;
+ if (found) found.s.pending = null; // one attempt per /login
+ if (!pending || Date.now() - pending.at > PENDING_TTL_MS) return page(res, 400, 'Sign-in expired', 'Start again from the sign-in link.');
+ let code;
+ try {
+ ({ code } = readCallback(`http://app.invalid${req.url}`, { expectedState: pending.state }));
+ } catch (err) {
+ log.log(`[oauth] callback refused: ${err.code}`);
+ return page(res, 400, 'Sign-in failed', err.code === 'oauth.state_mismatch' ? 'This sign-in was not started here.' : `Network answered ${err.code}.`, err.code);
+ }
+ try {
+ const tokens = await exchangeAppCode(client, { ...config, code, codeVerifier: pending.codeVerifier });
+ const d = await client.discover();
+ const claims = await verifyUserToken(tokens.access_token, {
+ jwks: d.jwksUri || `${config.network}/api/.well-known/jwks`, issuer: d.issuer || undefined, audience: config.audience,
+ allowServiceTokens: true, fetch,
+ });
+ const subject = signedInSubject(claims, config.clientId);
+ if (!subject) return page(res, 400, 'Sign-in failed', 'The token does not name a person for this app.', 'token.no_subject');
+ sessions.delete(found.sid); // fresh session id after sign-in
+ const sid = newSid();
+ sessions.set(sid, {
+ auth: {
+ subject, app: claims.sub || null, projectId: claims.project_id || null, env: claims.env || null,
+ capabilities: claims.cap || String(tokens.scope || '').split(' ').filter(Boolean),
+ expiresAt: claims.exp * 1000, accessToken: tokens.access_token,
+ },
+ });
+ log.log(`[oauth] signed in ${subject}`);
+ res.writeHead(302, { Location: '/', 'Set-Cookie': cookie(sid, Math.max(60, claims.exp - Math.floor(Date.now() / 1000))), 'Cache-Control': 'no-store' });
+ return res.end();
+ } catch (err) {
+ log.error(`[oauth] exchange failed: ${isOpenVibeError(err) ? `${err.code} request=${err.requestId}` : err.message}`);
+ return page(res, 400, 'Sign-in failed', 'Network did not accept the sign-in. Start again.', isOpenVibeError(err) ? err.code : 'exchange_failed');
+ }
+ }
+
+ function page(res, status, title, text, code) {
+ res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
+ res.end(`<!doctype html><meta charset="utf-8"><title>${escapeHtml(title)}</title><h1>${escapeHtml(title)}</h1><p>${escapeHtml(text)}</p>${code ? `<p><code data-error="${escapeHtml(code)}">${escapeHtml(code)}</code></p>` : ''}<p><a href="/">Back</a></p>`);
+ }
+
+ const server = http.createServer(async (req, res) => {
+ const url = new URL(req.url, 'http://app.invalid');
+ try {
+ if (req.method === 'GET' && url.pathname === '/') {
+ const auth = current(req);
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
+ return res.end(`<!doctype html><meta charset="utf-8"><title>OpenVibe OAuth app example</title><h1>OpenVibe OAuth app example</h1>${auth
+ ? `<p>Signed in with OpenVibe as <code>${escapeHtml(auth.subject)}</code>.</p><p>This app may: ${auth.capabilities.map((c) => `<code>${escapeHtml(c)}</code>`).join(', ') || 'nothing'}.</p><form method="post" action="/logout"><button>Sign out</button></form>`
+ : '<p><a href="/login">Sign in with OpenVibe</a></p>'}`);
+ }
+ if (req.method === 'GET' && url.pathname === '/login') return await login(req, res);
+ if (req.method === 'GET' && url.pathname === new URL(config.redirectUri).pathname) return await callback(req, res);
+ if (req.method === 'GET' && url.pathname === '/api/me') {
+ const auth = current(req);
+ res.writeHead(auth ? 200 : 401, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ return res.end(JSON.stringify(auth
+ ? { subject: auth.subject, app: auth.app, project_id: auth.projectId, env: auth.env, capabilities: auth.capabilities, expires_at: new Date(auth.expiresAt).toISOString() }
+ : { error: 'not_signed_in' }));
+ }
+ if (req.method === 'POST' && url.pathname === '/logout') {
+ const found = sessionOf(req);
+ if (found) sessions.delete(found.sid);
+ res.writeHead(303, { Location: '/', 'Set-Cookie': cookie('', 0) });
+ return res.end();
+ }
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
+ return res.end('not found');
+ } catch (err) {
+ log.error(`[oauth] ${err.message}`);
+ if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('error'); }
+ return undefined;
+ }
+ });
+
+ return { server, sessions };
+}
+
+if (require.main === module) {
+ let config;
+ try { config = loadConfig(); } catch (err) { console.error(err.message); process.exit(2); }
+ const { server } = createApp(config);
+ server.listen(config.port, () => console.log(`oauth-app: http://localhost:${config.port} (redirect URI ${config.redirectUri})`));
+}
+
+module.exports = { loadConfig, createApp, exchangeAppCode, signedInSubject };
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+'use strict';
+/**
+ * Smoke test: authorization code + PKCE with a server-side exchange, against the SDK's mock
+ * platform. The mock has no /oauth/authorize page; platform.authorize() stands in for the person
+ * choosing "Continue" on Network, and it verifies PKCE at the token endpoint like Network does.
+ */
+const assert = require('node:assert/strict');
+const { createMockPlatform } = require('openvibe-sdk/testing');
+const { createApp, loadConfig, signedInSubject } = require('../server');
+
+const realFetch = globalThis.fetch;
+globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
+
+const APP = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TR';
+const SECRET = 'ovsec_oauth_app_test_secret_value';
+
+(async () => {
+ const platform = createMockPlatform({ users: [{ username: 'ana' }] });
+ const ana = [...platform.state.users.values()][0];
+ const logs = [];
+ const log = { log: (m) => logs.push(m), error: (m) => logs.push(m) };
+ const env = { OV_CLIENT_ID: APP, OV_CLIENT_SECRET: SECRET, OV_AUDIENCE: 'openvibe.media', OV_SCOPE: 'media.object.upload media.object.read' };
+ const app = createApp(loadConfig(env), { fetch: platform.fetch, log });
+ await new Promise((r) => app.server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${app.server.address().port}`;
+ const config = loadConfig(env);
+ platform.addClient(APP, { secret: SECRET, redirectUris: [config.redirectUri] });
+
+ const get = (path, cookie, method = 'GET') => realFetch(`${base}${path}`, { method, redirect: 'manual', headers: cookie ? { cookie } : {} });
+ const cookieOf = (res) => (res.headers.get('set-cookie') || '').split(';')[0];
+
+ /** /login -> the authorize URL's parameters and this browser's session cookie. */
+ async function startLogin() {
+ const res = await get('/login');
+ assert.equal(res.status, 302);
+ const to = new URL(res.headers.get('location'));
+ return { to, cookie: cookieOf(res) };
+ }
+ const callbackPath = (params) => `/callback?${new URLSearchParams(params)}`;
+
+ // 1. Sign in.
+ const { to, cookie } = await startLogin();
+ assert.equal(`${to.origin}${to.pathname}`, 'https://openvibe.network/oauth/authorize');
+ assert.equal(to.searchParams.get('client_id'), APP);
+ assert.equal(to.searchParams.get('redirect_uri'), config.redirectUri);
+ assert.equal(to.searchParams.get('code_challenge_method'), 'S256');
+ assert.match(to.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);
+ assert.equal(to.searchParams.get('scope'), 'media.object.upload media.object.read');
+ assert.match(cookie, /^ov_example_sid=/);
+ const res1 = await get('/login');
+ assert.match(res1.headers.get('set-cookie'), /HttpOnly; SameSite=Lax/);
+ assert.ok(!to.toString().includes(SECRET), 'the secret never goes to the browser');
+
+ const code = platform.authorize({ clientId: APP, redirectUri: config.redirectUri, subjectId: ana.subject_id, codeChallenge: to.searchParams.get('code_challenge'), scope: to.searchParams.get('scope') });
+ const cb = await get(callbackPath({ code, state: to.searchParams.get('state') }), cookie);
+ assert.equal(cb.status, 302);
+ assert.equal(cb.headers.get('location'), '/');
+ const signedIn = cookieOf(cb);
+ assert.notEqual(signedIn, cookie, 'a new session id after sign-in');
+
+ const me = await get('/api/me', signedIn);
+ assert.equal(me.status, 200);
+ const body = await me.json();
+ assert.equal(body.subject, ana.subject_id);
+ assert.ok(!JSON.stringify(body).includes('access_token'));
+ assert.match(await (await get('/', signedIn)).text(), new RegExp(`Signed in with OpenVibe as <code>${ana.subject_id}</code>`));
+
+ // The exchange sent the verifier, the secret and the audience to the token endpoint.
+ const exchange = platform.stats.requests.filter((r) => r.url.endsWith('/oauth/token'));
+ assert.equal(exchange.length, 1);
+ assert.equal((await get('/api/me', cookie)).status, 401, 'the pre-login session id is dead');
+
+ // 2. The same callback again (replayed link): refused, no second exchange.
+ const replay = await get(callbackPath({ code, state: to.searchParams.get('state') }), cookie);
+ assert.equal(replay.status, 400);
+
+ // 3. A callback this browser did not start (state mismatch, login CSRF): refused before any exchange.
+ const second = await startLogin();
+ const mismatch = await get(callbackPath({ code: 'whatever', state: 'not-the-state' }), second.cookie);
+ assert.equal(mismatch.status, 400);
+ assert.match(await mismatch.text(), /oauth\.state_mismatch/);
+
+ // 4. The person declined on Network.
+ const third = await startLogin();
+ const denied = await get(callbackPath({ error: 'access_denied', state: third.to.searchParams.get('state') }), third.cookie);
+ assert.equal(denied.status, 400);
+ assert.match(await denied.text(), /oauth\.access_denied/);
+
+ // 5. A code bound to a different PKCE challenge (stolen code): the token endpoint refuses it.
+ const fourth = await startLogin();
+ const other = await require('openvibe-sdk/auth').createPkcePair();
+ const stolen = platform.authorize({ clientId: APP, redirectUri: config.redirectUri, subjectId: ana.subject_id, codeChallenge: other.codeChallenge });
+ const pkce = await get(callbackPath({ code: stolen, state: fourth.to.searchParams.get('state') }), fourth.cookie);
+ assert.equal(pkce.status, 400);
+ assert.match(await pkce.text(), /invalid_grant/);
+
+ // 6. Sign out.
+ const out = await get('/logout', signedIn, 'POST');
+ assert.equal(out.status, 303);
+ assert.equal((await get('/api/me', signedIn)).status, 401);
+
+ // App tokens name the person in on_behalf_of and must be for this app.
+ assert.equal(signedInSubject({ actor_type: 'app', sub: `app:${APP}`, on_behalf_of: 'usr_X' }, APP), 'usr_X');
+ assert.equal(signedInSubject({ actor_type: 'app', sub: 'app:app_SOMEONEELSE', on_behalf_of: 'usr_X' }, APP), null);
+
+ // Nothing secret in the logs.
+ const text = logs.join('\n');
+ assert.ok(!text.includes(SECRET));
+ assert.ok(!/eyJ[\w-]+\.[\w-]+\./.test(text), 'no tokens in logs');
+
+ app.server.close();
+ console.log('oauth-app: ok');
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+# A CONFIDENTIAL developer app holding tools.job.create and tools.job.read (audience openvibe.tools).
+OV_NETWORK_URL=https://openvibe.network
+OV_CLIENT_ID=
+OV_CLIENT_SECRET=
+# The Tools satellite that runs the job type (img.process runs on img.openvibe.tools).
+OV_TOOLS_JOBS_URL=https://img.openvibe.tools
+OV_JOB_TYPE=img.process
+OV_JOB_INPUT={"tool":"convert","format":"webp"}
+OV_OUT_DIR=./out
+OV_JOB_STATE=./.job-state.json
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+# Tool job
+
+Run an OpenVibe.Tools job (here: convert an image to WebP) as a developer app, and survive
+dropped connections and restarts without running the job twice.
+
+```bash
+node --env-file=.env run-job.js ./photo.png
+# submitted job_01K… (queued)
+# job.queued
+# job.running
+# job.progress 50% converting
+# job.succeeded 100%
+# saved ./out/photo.webp
+```
+
+## What it proves
+
+- An app token for `openvibe.tools` scoped to `tools.job.create tools.job.read` (both `public`
+ capabilities a developer app can hold).
+- **Submit once.** `POST /api/v1/jobs` carries an `Idempotency-Key` derived from the request
+ (type, input, file). If the process dies after submitting, running it again gets the **same**
+ job back (`200 Idempotent-Replayed: true`), not a second one.
+- **Reattach.** The job id and the last event id are kept in `.job-state.json`; a restart
+ reattaches to the job instead of submitting.
+- **Resume the stream.** `GET /api/v1/jobs/:id/events` (SSE) is reopened with `Last-Event-ID`
+ after a drop, and Tools replays only later events: every event is seen once, in order.
+ `204` means the job finished and nothing is newer.
+- The result files are downloaded with the same token. Jobs are owner-scoped: another app gets `404`.
+
+## Files
+
+| File | What |
+|---|---|
+| `run-job.js` | `createJobRunner()` (`submit`, `watch`, `get`, `download`, `run`), `readEventStream()`, CLI |
+| `test/mock-tools-server.js` | a local `/api/v1/jobs` that behaves as documented (the SDK mock has no Tools) |
+| `test/smoke.test.js` | full run with a dropped stream, reattach after a crash, idempotent resubmit, 204, owner scoping, no grant |
+
+## Run the smoke test
+
+```bash
+npm test
+```
+
+Network is the SDK's mock platform; the Tools mock verifies its tokens (RS256, audience
+`openvibe.tools`, the `tools.job.*` capability) and plays queued → running → progress → succeeded.
+
+## Run it against the real platform
+
+1. Create a project and a **confidential** app, request `tools.job.create` and `tools.job.read`
+ ([walkthrough](../../README.md#end-to-end-walkthrough)).
+2. `cp .env.example .env`, fill in `OV_CLIENT_ID` and `OV_CLIENT_SECRET`.
+3. `node --env-file=.env run-job.js ./photo.png`
+
+`img.process` runs on `https://img.openvibe.tools`; audio and docs jobs run on their own
+satellites (set `OV_TOOLS_JOBS_URL`, `OV_JOB_TYPE`, `OV_JOB_INPUT`). Tools accepts app tokens on
+this API today. What has to be true on the platform side first: the capabilities are in your
+project's allowance, and your app can get an `openvibe.tools` token — a **sandbox** app cannot
+until staff list `openvibe.tools` in Network's `DEV_SANDBOX_AUDIENCES` (and Tools accepts
+sandbox tokens), so use a production app.
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+{
+ "name": "openvibe-example-tool-job",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Tool job: submit an OpenVibe.Tools job and reattach to its progress stream.",
+ "license": "MIT",
+ "main": "run-job.js",
+ "scripts": {
+ "start": "node --env-file-if-exists=.env run-job.js",
+ "test": "node test/smoke.test.js"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,228 @@
@@ -0,0 +1,228 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Tool job: run an OpenVibe.Tools job as a developer app and survive disconnects and restarts.
+ *
+ * node --env-file=.env run-job.js ./photo.png # img.process, convert to WebP by default
+ *
+ * 1. App token for openvibe.tools (client credentials, scope tools.job.create tools.job.read).
+ * 2. POST /api/v1/jobs (multipart: type, input, file) with an Idempotency-Key derived from the
+ * request, so re-running after a crash gets the SAME job back (200 Idempotent-Replayed).
+ * 3. The job id and the last event id are saved in a state file. Running the script again
+ * reattaches to the job instead of submitting a new one.
+ * 4. GET /api/v1/jobs/:id/events (SSE). When the stream drops, reconnect with Last-Event-ID: the
+ * server replays only later events. 204 means the job finished and nothing is newer.
+ * 5. GET /api/v1/jobs/:id for the result, then download each result file.
+ *
+ * openvibe-sdk has no jobs client yet (sdk-jobs), so the job routes are called with the SDK's core
+ * client, which still supplies the token, deadlines, safe retries, problem errors and tracing. The
+ * event stream is the exception: the core client cannot return a streaming body, so it is read
+ * with fetch and the same app token.
+ */
+const crypto = require('node:crypto');
+const fs = require('node:fs');
+const path = require('node:path');
+const { createClient, isOpenVibeError, OpenVibeError, startSpan } = require('openvibe-sdk/core');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+
+const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
+
+function loadConfig(env = process.env) {
+ const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET'].filter((k) => !env[k]);
+ if (missing.length) {
+ throw Object.assign(new Error(`missing environment variables: ${missing.join(', ')} (see .env.example)`), { code: 'config.missing' });
+ }
+ let input;
+ try { input = JSON.parse(env.OV_JOB_INPUT || '{"tool":"convert","format":"webp"}'); } catch {
+ throw Object.assign(new Error('OV_JOB_INPUT must be a JSON object'), { code: 'config.invalid' });
+ }
+ return {
+ network: env.OV_NETWORK_URL || 'https://openvibe.network',
+ clientId: env.OV_CLIENT_ID,
+ clientSecret: env.OV_CLIENT_SECRET,
+ toolsUrl: env.OV_TOOLS_JOBS_URL || 'https://img.openvibe.tools',
+ type: env.OV_JOB_TYPE || 'img.process',
+ input,
+ outDir: env.OV_OUT_DIR || path.join(process.cwd(), 'out'),
+ statePath: env.OV_JOB_STATE || path.join(process.cwd(), '.job-state.json'),
+ };
+}
+
+/** Parse a text/event-stream body: calls onEvent({ id, event, data }) per event, onRetry(ms). */
+async function readEventStream(body, onEvent, onRetry = () => {}) {
+ const reader = body.getReader();
+ const decoder = new TextDecoder();
+ let buf = '';
+ let cur = { data: [] };
+ const line = (l) => {
+ if (l === '') {
+ if (cur.data.length) onEvent({ id: cur.id, event: cur.event || 'message', data: cur.data.join('\n') });
+ cur = { data: [] };
+ return;
+ }
+ if (l.startsWith(':')) return;
+ const i = l.indexOf(':');
+ const field = i < 0 ? l : l.slice(0, i);
+ const value = i < 0 ? '' : l.slice(i + 1).replace(/^ /, '');
+ if (field === 'data') cur.data.push(value);
+ else if (field === 'event') cur.event = value;
+ else if (field === 'id') cur.id = value;
+ else if (field === 'retry' && /^\d+$/.test(value)) onRetry(Number(value));
+ };
+ for (;;) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ buf += decoder.decode(value, { stream: true });
+ let m;
+ while ((m = /\r\n|\n|\r/.exec(buf))) {
+ if (m[0] === '\r' && m.index === buf.length - 1) break;
+ line(buf.slice(0, m.index));
+ buf = buf.slice(m.index + m[0].length);
+ }
+ }
+}
+
+function createJobRunner(config, { fetch, log = console } = {}) {
+ const tokens = createServiceTokenClient({
+ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch,
+ scope: { 'openvibe.tools': 'tools.job.create tools.job.read' },
+ });
+ const client = createClient({ network: config.network, fetch, tokenProvider: tokens, baseUrls: { tools: config.toolsUrl } });
+ const fetchImpl = fetch || globalThis.fetch;
+
+ const loadState = () => { try { return JSON.parse(fs.readFileSync(config.statePath, 'utf8')); } catch { return null; } };
+ const saveState = (s) => {
+ const tmp = `${config.statePath}.tmp`;
+ fs.writeFileSync(tmp, JSON.stringify(s, null, 2));
+ fs.renameSync(tmp, config.statePath);
+ };
+ const clearState = () => { try { fs.unlinkSync(config.statePath); } catch { /* none */ } };
+
+ /** Submit, or reattach to the job this state file already names for the same request. */
+ async function submit(filePath) {
+ const bytes = await fs.promises.readFile(filePath);
+ const fingerprint = crypto.createHash('sha256').update(config.type).update('\0').update(JSON.stringify(config.input)).update('\0').update(bytes).digest('hex');
+ const saved = loadState();
+ if (saved && saved.fingerprint === fingerprint && saved.jobId) {
+ log.log(`reattaching to ${saved.jobId}`);
+ return { jobId: saved.jobId, reattached: true };
+ }
+ const form = new FormData();
+ form.append('type', config.type);
+ form.append('input', JSON.stringify(config.input));
+ form.append('file', new Blob([bytes]), path.basename(filePath));
+ const res = await client.request({
+ service: 'tools', method: 'POST', path: '/api/v1/jobs', form,
+ idempotencyKey: `ex-${fingerprint.slice(0, 40)}`, // same request -> same job, even after a crash
+ });
+ const job = res.data;
+ saveState({ jobId: job.id, fingerprint, lastEventId: 0, submittedAt: new Date().toISOString() });
+ log.log(`${res.headers.get('idempotent-replayed') ? 'existing' : 'submitted'} ${job.id} (${job.state})`);
+ return { jobId: job.id, reattached: false, replayed: Boolean(res.headers.get('idempotent-replayed')) };
+ }
+
+ /**
+ * Open the job's event stream. The SDK's core client cannot hand back a streaming body
+ * (responseType 'response' drops the Response), so this one call uses fetch directly with the
+ * same app token.
+ */
+ async function openStream(jobId, lastEventId) {
+ const url = `${config.toolsUrl.replace(/\/+$/, '')}/api/v1/jobs/${encodeURIComponent(jobId)}/events`;
+ for (let attempt = 0; attempt < 2; attempt++) {
+ const token = await tokens.getToken({ audience: 'openvibe.tools' });
+ const res = await fetchImpl(url, {
+ headers: { Accept: 'text/event-stream', Authorization: `Bearer ${token}`, 'Last-Event-ID': String(lastEventId), traceparent: startSpan().traceparent },
+ });
+ if (res.status === 401 && attempt === 0) { tokens.invalidate({ audience: 'openvibe.tools' }); continue; }
+ if (!res.ok) {
+ const body = await res.json().catch(() => null);
+ throw OpenVibeError.fromResponse({ status: res.status, body, method: 'GET', url });
+ }
+ return res;
+ }
+ throw new Error('unreachable');
+ }
+
+ /** Follow the job's events until it finishes, reconnecting with Last-Event-ID. */
+ async function watch(jobId, { onEvent = () => {}, maxReconnects = 20 } = {}) {
+ let retryMs = 1000;
+ let reconnects = 0;
+ for (;;) {
+ const state = loadState() || {};
+ const lastEventId = state.jobId === jobId ? state.lastEventId || 0 : 0;
+ let finished = false;
+ try {
+ const res = await openStream(jobId, lastEventId);
+ if (res.status === 204) return; // finished, nothing newer
+ await readEventStream(res.body, (e) => {
+ let data = null;
+ try { data = JSON.parse(e.data); } catch { /* keep null */ }
+ if (e.id) saveState({ ...(loadState() || {}), jobId, lastEventId: Number(e.id) });
+ onEvent({ id: Number(e.id), event: e.event, job: data });
+ if (data && TERMINAL.has(data.state) && e.event !== 'job.progress') finished = true;
+ }, (ms) => { retryMs = ms; });
+ } catch (err) {
+ if (isOpenVibeError(err) && err.status >= 400 && err.status < 500) throw err; // 401/403/404: do not loop
+ log.error(`event stream interrupted: ${isOpenVibeError(err) ? err.code : err.message}`);
+ }
+ if (finished) return;
+ if (++reconnects > maxReconnects) throw new Error(`gave up after ${maxReconnects} reconnects`);
+ await new Promise((r) => setTimeout(r, retryMs));
+ }
+ }
+
+ async function get(jobId) {
+ return client.json({ service: 'tools', path: `/api/v1/jobs/${encodeURIComponent(jobId)}` });
+ }
+
+ async function download(job) {
+ const saved = [];
+ fs.mkdirSync(config.outDir, { recursive: true });
+ for (const [n, f] of ((job.result && job.result.files) || []).entries()) {
+ const res = await client.request({ service: 'tools', path: `/api/v1/jobs/${encodeURIComponent(job.id)}/files/${n}`, responseType: 'arrayBuffer' });
+ const name = path.basename(String(f.name || `result-${n}`)).replace(/[^\w.-]/g, '_');
+ const out = path.join(config.outDir, name);
+ fs.writeFileSync(out, Buffer.from(res.data));
+ saved.push(out);
+ }
+ return saved;
+ }
+
+ /** submit -> watch -> result -> download; clears the state file once the job is done. */
+ async function run(filePath, { onEvent } = {}) {
+ const { jobId } = await submit(filePath);
+ await watch(jobId, { onEvent });
+ const job = await get(jobId);
+ const files = job.state === 'succeeded' ? await download(job) : [];
+ clearState();
+ return { job, files };
+ }
+
+ return { client, submit, watch, get, download, run, loadState };
+}
+
+async function main([file] = process.argv.slice(2)) {
+ if (!file) { console.error('usage: node run-job.js <file>'); return 2; }
+ try {
+ const runner = createJobRunner(loadConfig());
+ const { job, files } = await runner.run(file, {
+ onEvent: ({ event, job: j }) => {
+ const pct = j && j.progress && j.progress.percent != null ? ` ${j.progress.percent}%` : '';
+ console.log(`${event}${pct}${j && j.progress && j.progress.message ? ` ${j.progress.message}` : ''}`);
+ },
+ });
+ if (job.state !== 'succeeded') {
+ console.error(`job ${job.id} ${job.state}${job.error ? `: ${job.error.code} ${job.error.detail || ''}` : ''}`);
+ return 1;
+ }
+ for (const f of files) console.log(`saved ${f}`);
+ return 0;
+ } catch (err) {
+ console.error(`job failed: ${isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''}` : err.message}`);
+ return 1;
+ }
+}
+
+if (require.main === module) main().then((code) => { process.exitCode = code; });
+
+module.exports = { loadConfig, createJobRunner, readEventStream, main };
new file mode 100644
new file mode 100644
@@ -0,0 +1,173 @@
@@ -0,0 +1,173 @@
+'use strict';
+/**
+ * A small local stand-in for OpenVibe.Tools' job API (/api/v1/jobs), for tests only. The SDK's
+ * createMockPlatform() has no Tools, so this mock plays the documented behaviour:
+ *
+ * POST /api/v1/jobs multipart type/input/file, Idempotency-Key -> 202 (200 + Idempotent-Replayed on repeat)
+ * GET /api/v1/jobs/:id the job view
+ * GET /api/v1/jobs/:id/events SSE with ids; Last-Event-ID replays only later events; 204 when finished and nothing newer
+ * GET /api/v1/jobs/:id/files/:n a result file
+ *
+ * Tokens are verified against the mock platform's key: RS256, audience openvibe.tools, the
+ * tools.job.* capability for the action. Jobs are owner-scoped (the token's sub).
+ */
+const crypto = require('node:crypto');
+const http = require('node:http');
+
+const ULID = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+const newJobId = () => `job_${Array.from(crypto.randomBytes(26), (b) => ULID[b & 31]).join('')}`;
+const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
+const CAPS = { create: 'tools.job.create', read: 'tools.job.read' };
+
+function createMockToolsServer({ publicKey, stepMs = 25, dropStreamsAfter = 0 } = {}) {
+ const jobs = new Map();
+ const idem = new Map();
+ const listeners = new Map();
+ const stats = { submits: 0, streams: [], replays: 0 };
+ let drops = dropStreamsAfter;
+
+ function verify(req, action) {
+ const auth = String(req.headers.authorization || '');
+ const parts = auth.startsWith('Bearer ') ? auth.slice(7).split('.') : [];
+ if (parts.length !== 3) return { error: [401, 'token.missing'] };
+ let header, claims;
+ try {
+ header = JSON.parse(Buffer.from(parts[0], 'base64url'));
+ claims = JSON.parse(Buffer.from(parts[1], 'base64url'));
+ } catch { return { error: [401, 'token.malformed'] }; }
+ if (header.alg !== 'RS256' || !crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, Buffer.from(parts[2], 'base64url'))) return { error: [401, 'token.bad_signature'] };
+ if (!claims.exp || claims.exp * 1000 < Date.now()) return { error: [401, 'token.expired'] };
+ if (!(claims.aud || []).includes('openvibe.tools')) return { error: [401, 'token.wrong_audience'] };
+ if (!(claims.cap || []).includes(CAPS[action])) return { error: [403, 'capability.denied'] };
+ return { owner: claims.sub };
+ }
+
+ function view(job) {
+ return {
+ id: job.id, object: 'tools.job', service: 'img', type: job.type, type_version: 1, state: job.state,
+ progress: { percent: job.percent, message: job.message }, attempts: job.state === 'queued' ? 0 : 1, max_attempts: 2,
+ result: job.result ? { files: job.result.files.map((f, i) => ({ name: f.name, mime: f.mime, size: f.bytes.length, url: `/api/v1/jobs/${job.id}/files/${i}` })), data: job.result.data } : null,
+ error: null, retryable: false,
+ links: { self: `/api/v1/jobs/${job.id}`, events: `/api/v1/jobs/${job.id}/events`, cancel: TERMINAL.has(job.state) ? null : `/api/v1/jobs/${job.id}` },
+ };
+ }
+
+ function emit(job, event) {
+ const e = { seq: job.events.length + 1, event, data: view(job) };
+ job.events.push(e);
+ for (const fn of listeners.get(job.id) || []) fn(e);
+ }
+
+ function lifecycle(job) {
+ const steps = [
+ () => { job.state = 'running'; emit(job, 'job.running'); },
+ () => { job.percent = 50; job.message = 'converting'; emit(job, 'job.progress'); },
+ () => {
+ job.state = 'succeeded'; job.percent = 100; job.message = null;
+ job.result = { files: [{ name: job.fileName.replace(/\.[^.]+$/, '') + '.' + (job.input.format || 'out'), mime: `image/${job.input.format || 'png'}`, bytes: Buffer.concat([Buffer.from('converted:'), job.bytes]) }], data: { tool: job.input.tool } };
+ emit(job, 'job.succeeded');
+ },
+ ];
+ const next = () => { const s = steps.shift(); if (s) { s(); setTimeout(next, stepMs); } };
+ setTimeout(next, stepMs);
+ }
+
+ const problem = (res, status, code, detail) => {
+ res.writeHead(status, { 'Content-Type': 'application/problem+json' });
+ res.end(JSON.stringify({ type: `https://openvibe.network/problems/${code}`, title: String(status), status, code, detail: detail || code }));
+ };
+
+ const server = http.createServer(async (req, res) => {
+ const url = new URL(req.url, 'http://tools.invalid');
+ let m;
+ if (req.method === 'POST' && url.pathname === '/api/v1/jobs') {
+ const who = verify(req, 'create');
+ if (who.error) return problem(res, ...who.error);
+ const chunks = [];
+ for await (const c of req) chunks.push(c);
+ const raw = Buffer.concat(chunks);
+ const form = await new Request('http://tools.invalid/', { method: 'POST', headers: { 'content-type': req.headers['content-type'] }, body: raw }).formData();
+ const key = String(req.headers['idempotency-key'] || '');
+ if (key.length < 8 || key.length > 200) return problem(res, 400, 'tools.job.invalid', 'Idempotency-Key must be 8-200 characters');
+ // Like Tools: the request is identified by type, input and the files' hashes (not the multipart bytes).
+ const fileHashes = [];
+ for (const f of form.getAll('file')) if (typeof f !== 'string') fileHashes.push(crypto.createHash('sha256').update(Buffer.from(await f.arrayBuffer())).digest('hex'));
+ const hash = crypto.createHash('sha256').update(JSON.stringify([String(form.get('type')), String(form.get('input') || '{}'), fileHashes])).digest('hex');
+ const prior = idem.get(`${who.owner}|${key}`);
+ if (prior) {
+ if (prior.hash !== hash) return problem(res, 409, 'tools.job.idempotency_conflict', 'This Idempotency-Key was used for a different request');
+ stats.replays++;
+ res.writeHead(200, { 'Content-Type': 'application/json', Location: `/api/v1/jobs/${prior.id}`, 'Idempotent-Replayed': 'true' });
+ return res.end(JSON.stringify(view(jobs.get(prior.id))));
+ }
+ const file = form.get('file');
+ if (!file || typeof file === 'string') return problem(res, 400, 'tools.job.invalid', 'one file is required');
+ stats.submits++;
+ const job = {
+ id: newJobId(), owner: who.owner, type: String(form.get('type')), input: JSON.parse(String(form.get('input') || '{}')),
+ state: 'queued', percent: null, message: null, events: [], result: null,
+ fileName: file.name, bytes: Buffer.from(await file.arrayBuffer()),
+ };
+ jobs.set(job.id, job);
+ idem.set(`${who.owner}|${key}`, { id: job.id, hash });
+ emit(job, 'job.queued');
+ lifecycle(job);
+ res.writeHead(202, { 'Content-Type': 'application/json', Location: `/api/v1/jobs/${job.id}` });
+ return res.end(JSON.stringify(view(job)));
+ }
+ if ((m = url.pathname.match(/^\/api\/v1\/jobs\/([^/]+)(\/events|\/files\/(\d+))?$/)) && req.method === 'GET') {
+ const who = verify(req, 'read');
+ if (who.error) return problem(res, ...who.error);
+ const job = jobs.get(m[1]);
+ if (!job || job.owner !== who.owner) return problem(res, 404, 'tools.job.not_found', 'No such job');
+ if (!m[2]) {
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+ return res.end(JSON.stringify(view(job)));
+ }
+ if (m[3] !== undefined) {
+ if (job.state !== 'succeeded') return problem(res, 409, 'tools.job.not_ready', `The job is ${job.state}`);
+ const f = job.result.files[Number(m[3])];
+ if (!f) return problem(res, 404, 'tools.job.file_not_found', 'No such result file');
+ res.writeHead(200, { 'Content-Type': f.mime, 'Content-Disposition': `attachment; filename="${f.name}"` });
+ return res.end(f.bytes);
+ }
+ const after = Math.max(0, parseInt(req.headers['last-event-id'] || url.searchParams.get('last_event_id') || '0', 10) || 0);
+ stats.streams.push(after);
+ const pending = job.events.filter((e) => e.seq > after);
+ if (TERMINAL.has(job.state) && !pending.length) { res.writeHead(204); return res.end(); }
+ res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store' });
+ res.write(`retry: ${stepMs}\n\n`);
+ let last = after;
+ let written = 0;
+ const set = listeners.get(job.id) || new Set();
+ listeners.set(job.id, set);
+ const close = () => { set.delete(write); res.end(); };
+ const write = (e) => {
+ if (e.seq <= last || res.writableEnded) return;
+ last = e.seq;
+ res.write(`id: ${e.seq}\nevent: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`);
+ written++;
+ if (TERMINAL.has(e.data.state) && e.event !== 'job.progress') return setImmediate(close);
+ if (drops > 0 && written >= 1) { drops--; setImmediate(close); } // simulate a dropped connection
+ };
+ set.add(write);
+ for (const e of job.events) write(e);
+ req.on('close', () => set.delete(write));
+ return undefined;
+ }
+ return problem(res, 404, 'not_found', 'Not found');
+ });
+
+ return {
+ server,
+ stats,
+ jobs,
+ async listen() {
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ return `http://127.0.0.1:${server.address().port}`;
+ },
+ close: () => new Promise((r) => { server.closeAllConnections(); server.close(r); }),
+ };
+}
+
+module.exports = { createMockToolsServer };
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+'use strict';
+/**
+ * Smoke test: submit -> watch (with a dropped stream) -> result -> download, reattach after a
+ * restart, idempotent resubmission. Network is the SDK's mock platform; Tools is the local mock in
+ * this folder (the SDK mock has no Tools). No network.
+ */
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { createMockPlatform } = require('openvibe-sdk/testing');
+const { createJobRunner, loadConfig } = require('../run-job');
+const { createMockToolsServer } = require('./mock-tools-server');
+
+const realFetch = globalThis.fetch;
+globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
+
+const APP = 'app_01JEXAMPLETOOLJOB000000000';
+const OTHER = 'app_01JANOTHERAPP0000000000000';
+const SECRET = 'ovsec_tool_job_test_secret';
+const quiet = { log() {}, error() {} };
+
+(async () => {
+ const toolsGrants = [{ capability: 'tools.job.create', audience: 'openvibe.tools' }, { capability: 'tools.job.read', audience: 'openvibe.tools' }];
+ const platform = createMockPlatform({
+ clients: { [APP]: { secret: SECRET, grants: toolsGrants }, [OTHER]: { secret: 'o', grants: toolsGrants }, app_01JNOGRANT0000000000000000: { secret: 'n', grants: [] } },
+ });
+ const tools = createMockToolsServer({ publicKey: platform.keys.publicKey, dropStreamsAfter: 1 });
+ const toolsUrl = await tools.listen();
+ // Network calls go to the mock platform, Tools calls to the local mock server.
+ const fetch = (input, init) => (String(input && input.url ? input.url : input).startsWith(toolsUrl) ? realFetch(input, init) : platform.fetch(input, init));
+
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-tooljob-'));
+ const file = path.join(dir, 'photo.png');
+ fs.writeFileSync(file, Buffer.from('not really a png, the mock does not care'));
+ const env = { OV_CLIENT_ID: APP, OV_CLIENT_SECRET: SECRET, OV_TOOLS_JOBS_URL: toolsUrl, OV_OUT_DIR: path.join(dir, 'out'), OV_JOB_STATE: path.join(dir, 'state.json') };
+
+ // 1. The whole flow, with the event stream dropped after its first event.
+ const seen = [];
+ const runner = createJobRunner(loadConfig(env), { fetch, log: quiet });
+ const { job, files } = await runner.run(file, { onEvent: (e) => seen.push(e) });
+ assert.equal(job.state, 'succeeded');
+ assert.deepEqual(seen.map((e) => e.event), ['job.queued', 'job.running', 'job.progress', 'job.succeeded']);
+ assert.deepEqual(seen.map((e) => e.id), [1, 2, 3, 4], 'every event once, in order');
+ assert.deepEqual(tools.stats.streams.slice(0, 2), [0, 1], 'reconnected with Last-Event-ID: 1');
+ assert.equal(files.length, 1);
+ assert.equal(path.basename(files[0]), 'photo.webp');
+ assert.equal(fs.readFileSync(files[0], 'utf8'), 'converted:not really a png, the mock does not care');
+ assert.equal(fs.existsSync(env.OV_JOB_STATE), false, 'state cleared once done');
+ assert.equal(tools.stats.submits, 1);
+
+ // 2. Crash after submitting: a new process with the same state file reattaches, no second job.
+ const file2 = path.join(dir, 'second.png');
+ fs.writeFileSync(file2, 'second image');
+ const crashed = createJobRunner(loadConfig(env), { fetch, log: quiet });
+ const first = await crashed.submit(file2);
+ assert.equal(first.reattached, false);
+ const restarted = createJobRunner(loadConfig(env), { fetch, log: quiet });
+ const again = await restarted.run(file2);
+ assert.equal(again.job.id, first.jobId);
+ assert.equal(tools.stats.submits, 2, 'reattached instead of resubmitting');
+
+ // 3. Lost state file: the Idempotency-Key is derived from the request, so Tools replays the same job.
+ const lost = createJobRunner(loadConfig(env), { fetch, log: quiet });
+ const replay = await lost.submit(file2);
+ assert.equal(replay.jobId, first.jobId);
+ assert.equal(replay.replayed, true);
+ assert.equal(tools.stats.submits, 2);
+
+ // 4. A finished job with nothing newer answers 204: watch() returns at once.
+ const t0 = Date.now();
+ await lost.watch(first.jobId);
+ assert.ok(Date.now() - t0 < 1000);
+
+ // 5. Jobs are owner-scoped: another app gets 404, and a 4xx is not retried in a loop.
+ const other = createJobRunner(loadConfig({ ...env, OV_CLIENT_ID: OTHER, OV_CLIENT_SECRET: 'o', OV_JOB_STATE: path.join(dir, 'other.json') }), { fetch, log: quiet });
+ await assert.rejects(other.get(first.jobId), (err) => err.status === 404 && err.code === 'tools.job.not_found');
+ await assert.rejects(other.watch(first.jobId), (err) => err.status === 404);
+
+ // 6. No grant: Network refuses the token, nothing reaches Tools.
+ const before = tools.stats.submits;
+ const nogrant = createJobRunner(loadConfig({ ...env, OV_CLIENT_ID: 'app_01JNOGRANT0000000000000000', OV_CLIENT_SECRET: 'n' }), { fetch, log: quiet });
+ await assert.rejects(nogrant.submit(path.join(dir, 'photo.png')), (err) => err.code === 'invalid_scope');
+ assert.equal(tools.stats.submits, before);
+
+ await tools.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ console.log('tool-job: ok');
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
+# server.js: the subscription's signing secret (subscribe.js writes it to .env.webhook).
+OV_WEBHOOK_SECRET=
+# While rotating: the previous secret, accepted until you remove it.
+OV_WEBHOOK_SECRET_PREVIOUS=
+OV_WEBHOOK_PATH=/webhooks/openvibe
+OV_DB_PATH=./data/webhook-consumer.db
+OV_PORT=3003
+
+# subscribe.js: an app holding events.subscription.manage (audience openvibe.events).
+OV_NETWORK_URL=https://openvibe.network
+OV_CLIENT_ID=
+OV_CLIENT_SECRET=
+# Optional: an explicit Events origin instead of the one the registry advertises.
+OV_EVENTS_URL=
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+# Webhook consumer
+
+Receive OpenVibe.Events deliveries over HTTPS, verify `X-OpenVibe-Signature`, and handle every
+event **exactly once** even though delivery is at least once.
+
+```bash
+node --env-file=.env subscribe.js 'media.object.*' https://hooks.example.com/webhooks/openvibe
+node --env-file=.env --env-file=.env.webhook server.js # POST /webhooks/openvibe on :3003
+```
+
+## What it proves
+
+- **Signature first, on the raw bytes.** `openvibe-sdk/events` `parseDelivery()` checks
+ `sha256=<HMAC-SHA256 of the raw body>` in constant time before anything is parsed. A wrong
+ secret, a changed byte or a missing header is `401`, with no detail.
+- **Exactly once.** `createInbox(db).once(consumer, event_id, fn)` writes the receipt and runs your
+ handler in one SQLite transaction. A redelivery (a retry after a lost `2xx`, a replay) is
+ answered `200 { duplicate: true }` without running the handler. A handler that throws leaves
+ no receipt, the consumer answers `500`, and Events retries with backoff.
+- **The signed body is the truth.** `X-OpenVibe-Event-Id` must match the body's `event_id`.
+- **Secret rotation** without downtime: `OV_WEBHOOK_SECRET_PREVIOUS` is accepted while it is set.
+- `subscribe.js` creates the subscription with `events.subscriptions.create()`. It generates the
+ signing secret itself and writes it to `.env.webhook` (mode 0600); it never prints it.
+
+## Files
+
+| File | What |
+|---|---|
+| `server.js` | `createConsumer()`: the HTTP endpoint, signature check, inbox, `received_events` table |
+| `subscribe.js` | `createSubscription()`: create the Events subscription with an app token |
+| `test/smoke.test.js` | subscription via the SDK mock, then deliveries signed and shaped exactly like the Events delivery worker's |
+
+## Run the smoke test
+
+```bash
+npm test
+```
+
+The SDK's mock platform stores subscriptions but has no delivery worker, so the test plays the
+worker: it signs each body with `signDelivery()` and sends the same headers Events sends
+(`X-OpenVibe-Event-Id`, `-Event-Type`, `-Seq`, `-Subscription-Id`, `-Delivery-Attempt`, `-Signature`).
+
+## Run it against the real platform
+
+**This cannot be demonstrated against the real platform yet.** The consumer side is ready, but a
+developer app cannot create a subscription:
+
+- `events.subscription.manage` (and every other `events.*` capability) has visibility `internal`
+ in openvibe-contracts, so Network never grants it to an app.
+- OpenVibe.Events' public host exposes only `/realtime/stream` and health; its subscription API
+ is reachable from the platform's own hosts only.
+- The Events delivery worker only POSTs to endpoint hosts on its allow-list.
+
+When those change, the steps are: an app with `events.subscription.manage`, `cp .env.example .env`,
+`node --env-file=.env subscribe.js '<topic pattern>' https://<your host>/webhooks/openvibe`, then run
+`server.js` behind HTTPS with `--env-file=.env.webhook`.
+
+Signature scheme note: the HMAC covers the body only, with no timestamp, so a captured delivery
+can be replayed later. The inbox makes a replay harmless (it is a duplicate), which is why the
+inbox is not optional.
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "name": "openvibe-example-webhook-consumer",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Webhook consumer: verify X-OpenVibe-Signature and process each event exactly once through an inbox.",
+ "license": "MIT",
+ "main": "server.js",
+ "scripts": {
+ "start": "node --env-file-if-exists=.env server.js",
+ "test": "node test/smoke.test.js"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
+ "better-sqlite3": "^11.10.0"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Webhook consumer: receives OpenVibe.Events deliveries, verifies their signature and handles each
+ * event exactly once.
+ *
+ * node --env-file=.env server.js # POST http://localhost:3003/webhooks/openvibe
+ *
+ * A delivery is POSTed as { "event": <events.event-envelope@1>, "seq": <n> } with
+ * X-OpenVibe-Signature: sha256=<HMAC-SHA256 of the raw body with the subscription secret>.
+ *
+ * 1. Read the RAW body (the signature covers the exact bytes; never re-serialize JSON first).
+ * 2. openvibe-sdk/events parseDelivery() checks the signature in constant time and parses it.
+ * A bad signature is 401 and nothing else is said.
+ * 3. Delivery is at least once (retries, replays). inbox.once() records the event id and runs
+ * the handler in ONE SQLite transaction, so a repeat is answered 200 without running it again,
+ * and a handler that throws leaves no receipt: the 500 makes Events retry later.
+ *
+ * Secret rotation: set OV_WEBHOOK_SECRET_PREVIOUS to the old secret while both are in use.
+ */
+const fs = require('node:fs');
+const http = require('node:http');
+const path = require('node:path');
+const Database = require('better-sqlite3');
+const { parseDelivery, createInbox } = require('openvibe-sdk/events');
+
+const MAX_BODY = 1024 * 1024;
+const CONSUMER = 'webhook-consumer';
+
+function loadConfig(env = process.env) {
+ if (!env.OV_WEBHOOK_SECRET) {
+ const err = new Error('missing environment variable: OV_WEBHOOK_SECRET (see .env.example)');
+ err.code = 'config.missing';
+ throw err;
+ }
+ return {
+ secrets: [env.OV_WEBHOOK_SECRET, env.OV_WEBHOOK_SECRET_PREVIOUS].filter(Boolean),
+ dbPath: env.OV_DB_PATH || path.join(__dirname, 'data', 'webhook-consumer.db'),
+ port: Number(env.OV_PORT || 3003),
+ path: env.OV_WEBHOOK_PATH || '/webhooks/openvibe',
+ };
+}
+
+function openDatabase(dbPath) {
+ if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(dbPath), { recursive: true });
+ const db = new Database(dbPath);
+ db.pragma('journal_mode = WAL');
+ db.exec(`CREATE TABLE IF NOT EXISTS received_events (
+ event_id TEXT PRIMARY KEY,
+ event_type TEXT NOT NULL,
+ source TEXT NOT NULL,
+ seq INTEGER,
+ subject TEXT,
+ received_at TEXT NOT NULL
+ )`);
+ return db;
+}
+
+/**
+ * createConsumer(config, { db, handle, log })
+ * handle(event, { seq, attempt }) runs inside the inbox transaction and must be synchronous
+ * (better-sqlite3). Put side effects that need the network in your own outbox table instead.
+ */
+function createConsumer(config, { db = openDatabase(config.dbPath), handle = () => {}, log = console } = {}) {
+ const inbox = createInbox(db);
+ inbox.ensureSchema();
+ const record = db.prepare('INSERT INTO received_events (event_id, event_type, source, seq, subject, received_at) VALUES (?, ?, ?, ?, ?, ?)');
+
+ function verify(raw, headers) {
+ for (const secret of config.secrets) {
+ const d = parseDelivery(raw, headers, secret);
+ if (d) return d;
+ }
+ return null;
+ }
+
+ function receive(raw, headers) {
+ const delivery = verify(raw, headers);
+ if (!delivery) return { status: 401, body: { error: 'bad_signature' } };
+ const { event, seq, attempt } = delivery;
+ if (!event || typeof event.event_id !== 'string' || typeof event.event_type !== 'string') return { status: 400, body: { error: 'bad_envelope' } };
+ // The header is informational; the signed body is the truth. They must agree.
+ const headerId = headers['x-openvibe-event-id'];
+ if (headerId && headerId !== event.event_id) return { status: 400, body: { error: 'event_id_mismatch' } };
+ const out = inbox.once(CONSUMER, event.event_id, () => {
+ record.run(event.event_id, event.event_type, String(event.source || ''), Number.isFinite(seq) ? seq : null,
+ event.subject ? `${event.subject.type}:${event.subject.id}` : null, new Date().toISOString());
+ return handle(event, { seq, attempt });
+ });
+ log.log(`[webhook] ${event.event_type} ${event.event_id} seq=${seq} attempt=${attempt}${out.duplicate ? ' (duplicate, skipped)' : ''}`);
+ return { status: 200, body: { ok: true, duplicate: out.duplicate } };
+ }
+
+ const server = http.createServer((req, res) => {
+ const send = (status, body) => {
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
+ res.end(JSON.stringify(body));
+ };
+ const url = new URL(req.url, 'http://consumer.invalid');
+ if (req.method === 'GET' && url.pathname === '/healthz') return send(200, { ok: true });
+ if (req.method !== 'POST' || url.pathname !== config.path) return send(404, { error: 'not_found' });
+ const chunks = [];
+ let size = 0;
+ let tooLarge = false;
+ req.on('data', (c) => {
+ size += c.length;
+ if (size > MAX_BODY) { tooLarge = true; chunks.length = 0; return; }
+ chunks.push(c);
+ });
+ req.on('end', () => {
+ if (tooLarge) return send(413, { error: 'too_large' });
+ try {
+ const out = receive(Buffer.concat(chunks), req.headers);
+ send(out.status, out.body);
+ } catch (err) {
+ // No receipt was written: Events retries this delivery with backoff.
+ log.error(`[webhook] handler failed: ${err.message}`);
+ send(500, { error: 'handler_failed' });
+ }
+ });
+ });
+
+ return { server, db, inbox, receive };
+}
+
+if (require.main === module) {
+ let config;
+ try { config = loadConfig(); } catch (err) { console.error(err.message); process.exit(2); }
+ const { server } = createConsumer(config, {
+ handle(event) {
+ // Your side effect goes here (synchronous, same transaction as the receipt).
+ },
+ });
+ server.listen(config.port, () => console.log(`webhook-consumer: POST http://localhost:${config.port}${config.path}`));
+}
+
+module.exports = { loadConfig, openDatabase, createConsumer, CONSUMER };
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Create the OpenVibe.Events subscription that delivers to this consumer.
+ *
+ * node --env-file=.env subscribe.js 'media.object.*' https://hooks.example.com/webhooks/openvibe
+ *
+ * Needs an app token for openvibe.events holding events.subscription.manage. The signing secret
+ * is generated here and sent with the request, then written to .env.webhook (mode 0600) for
+ * server.js to read. It is never printed.
+ */
+const crypto = require('node:crypto');
+const fs = require('node:fs');
+const path = require('node:path');
+const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+const { createEventsClient } = require('openvibe-sdk/events');
+
+function loadConfig(env = process.env) {
+ const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET'].filter((k) => !env[k]);
+ if (missing.length) {
+ const err = new Error(`missing environment variables: ${missing.join(', ')} (see .env.example)`);
+ err.code = 'config.missing';
+ throw err;
+ }
+ return {
+ network: env.OV_NETWORK_URL || 'https://openvibe.network',
+ clientId: env.OV_CLIENT_ID,
+ clientSecret: env.OV_CLIENT_SECRET,
+ eventsUrl: env.OV_EVENTS_URL || null,
+ };
+}
+
+/** -> { id, topic_pattern, endpoint, enabled } plus the secret it was created with (kept out of logs). */
+async function createSubscription(config, { topicPattern, endpoint, secret = `whsec_${crypto.randomBytes(32).toString('hex')}` }, { fetch } = {}) {
+ if (!/^https:\/\//.test(endpoint) && !/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?\//.test(endpoint)) {
+ throw new TypeError('the endpoint must be an https:// URL');
+ }
+ const client = createClient({
+ network: config.network,
+ fetch,
+ tokenProvider: createServiceTokenClient({ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch }),
+ baseUrls: config.eventsUrl ? { events: config.eventsUrl } : undefined,
+ });
+ const events = createEventsClient(client, { source: config.clientId });
+ const sub = await events.subscriptions.create({ topicPattern, endpoint, secret });
+ return { subscription: { id: sub.id, topic_pattern: sub.topic_pattern, endpoint: sub.endpoint, enabled: sub.enabled }, secret: sub.secret || secret };
+}
+
+async function main([topicPattern, endpoint] = process.argv.slice(2)) {
+ if (!topicPattern || !endpoint) {
+ console.error('usage: node subscribe.js <topic-pattern> <https endpoint>');
+ return 2;
+ }
+ try {
+ const { subscription, secret } = await createSubscription(loadConfig(), { topicPattern, endpoint });
+ const file = path.join(__dirname, '.env.webhook');
+ fs.writeFileSync(file, `OV_WEBHOOK_SECRET=${secret}\n`, { mode: 0o600 });
+ console.log(JSON.stringify(subscription, null, 2));
+ console.log(`signing secret written to ${file} (start the consumer with --env-file=.env.webhook)`);
+ return 0;
+ } catch (err) {
+ console.error(`subscribe failed: ${isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''}` : err.message}`);
+ if (isOpenVibeError(err) && ['invalid_scope', 'invalid_target', 'capability.denied'].includes(err.code)) {
+ console.error('events.subscription.manage is not grantable to developer apps today; see the README.');
+ }
+ return 1;
+ }
+}
+
+if (require.main === module) main().then((code) => { process.exitCode = code; });
+
+module.exports = { loadConfig, createSubscription, main };
new file mode 100644
new file mode 100644
@@ -0,0 +1,142 @@
@@ -0,0 +1,142 @@
+'use strict';
+/**
+ * Smoke test: subscription creation against the SDK's mock platform, then deliveries shaped and
+ * signed exactly like OpenVibe.Events' delivery worker sends them (the mock has no delivery
+ * worker, so this test plays that part with openvibe-sdk/events signDelivery()).
+ */
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { createMockPlatform } = require('openvibe-sdk/testing');
+const { signDelivery } = require('openvibe-sdk/events');
+const { ulid } = require('openvibe-sdk/core');
+const { createConsumer, loadConfig, openDatabase } = require('../server');
+const { createSubscription } = require('../subscribe');
+
+const realFetch = globalThis.fetch;
+globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
+
+const APP = 'app_01JEXAMPLEWEBHOOK000000000';
+const SECRET = 'ovsec_webhook_consumer_test_secret';
+
+function envelope(type = 'media.object.uploaded') {
+ return {
+ event_id: `evt_${ulid()}`, event_type: type, version: 1, source: 'media', timestamp: new Date().toISOString(),
+ actor: { type: 'service', id: 'media' }, subject: { type: 'object', id: 'med_01JEXAMPLEOBJECT0000000000' }, payload: { size: 12 },
+ };
+}
+
+/** POST a delivery the way the Events worker does. */
+async function deliver(port, event, seq, secret, { attempt = 1, tamper, headers = {} } = {}) {
+ const body = JSON.stringify({ event, seq });
+ const sent = tamper ? tamper(body) : body;
+ const res = await realFetch(`http://127.0.0.1:${port}/webhooks/openvibe`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-OpenVibe-Event-Id': event.event_id,
+ 'X-OpenVibe-Event-Type': event.event_type,
+ 'X-OpenVibe-Seq': String(seq),
+ 'X-OpenVibe-Subscription-Id': 'sub_test',
+ 'X-OpenVibe-Delivery-Attempt': String(attempt),
+ 'X-OpenVibe-Signature': signDelivery(body, secret),
+ ...headers,
+ },
+ body: sent,
+ });
+ return { status: res.status, body: await res.json() };
+}
+
+(async () => {
+ // 1. Create the subscription (events.subscription.manage) and keep the secret it was created with.
+ const platform = createMockPlatform({
+ clients: { [APP]: { secret: SECRET, grants: [{ capability: 'events.subscription.manage', audience: 'openvibe.events' }] } },
+ });
+ const created = await createSubscription(
+ { network: 'https://openvibe.network', clientId: APP, clientSecret: SECRET },
+ { topicPattern: 'media.object.*', endpoint: 'https://hooks.example.com/webhooks/openvibe' },
+ { fetch: platform.fetch },
+ );
+ assert.match(created.subscription.id, /^sub_/);
+ assert.equal(created.subscription.topic_pattern, 'media.object.*');
+ assert.equal('secret' in created.subscription, false, 'the printable part never carries the secret');
+ assert.match(created.secret, /^whsec_[0-9a-f]{64}$/);
+ await assert.rejects(createSubscription({ network: 'https://openvibe.network', clientId: APP, clientSecret: SECRET },
+ { topicPattern: 'x.y.*', endpoint: 'http://hooks.example.com/x' }, { fetch: platform.fetch }), /https/);
+
+ // 2. Run the consumer with that secret.
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-webhook-'));
+ const config = loadConfig({ OV_WEBHOOK_SECRET: created.secret, OV_DB_PATH: path.join(dir, 'consumer.db') });
+ const handled = [];
+ let failNext = false;
+ const logs = [];
+ const consumer = createConsumer(config, {
+ db: openDatabase(config.dbPath),
+ handle(event) {
+ if (failNext) { failNext = false; throw new Error('downstream unavailable'); }
+ handled.push(event.event_id);
+ },
+ log: { log: (m) => logs.push(m), error: (m) => logs.push(m) },
+ });
+ await new Promise((r) => consumer.server.listen(0, '127.0.0.1', r));
+ const { port } = consumer.server.address();
+
+ const e1 = envelope();
+ let r = await deliver(port, e1, 1, created.secret);
+ assert.deepEqual(r, { status: 200, body: { ok: true, duplicate: false } });
+ assert.deepEqual(handled, [e1.event_id]);
+
+ // Redelivery (a retry after a lost 2xx, or a replay): acknowledged, not processed again.
+ r = await deliver(port, e1, 1, created.secret, { attempt: 2 });
+ assert.deepEqual(r, { status: 200, body: { ok: true, duplicate: true } });
+ assert.deepEqual(handled, [e1.event_id]);
+
+ // Wrong secret, tampered body, missing signature: 401 and no side effect.
+ const e2 = envelope();
+ assert.equal((await deliver(port, e2, 2, 'whsec_not_the_secret')).status, 401);
+ assert.equal((await deliver(port, e2, 2, created.secret, { tamper: (b) => b.replace('"size":12', '"size":13') })).status, 401);
+ assert.equal((await deliver(port, e2, 2, created.secret, { headers: { 'X-OpenVibe-Signature': '' } })).status, 401);
+ assert.equal(handled.length, 1);
+
+ // Header and signed body disagree on the event id: refused.
+ assert.equal((await deliver(port, e2, 2, created.secret, { headers: { 'X-OpenVibe-Event-Id': 'evt_01JOTHER0000000000000000000' } })).status, 400);
+
+ // A handler failure leaves no receipt: 500 now, processed on the retry.
+ failNext = true;
+ assert.equal((await deliver(port, e2, 2, created.secret)).status, 500);
+ assert.equal(consumer.inbox.seen('webhook-consumer', e2.event_id), false);
+ r = await deliver(port, e2, 2, created.secret, { attempt: 2 });
+ assert.equal(r.status, 200);
+ assert.deepEqual(handled, [e1.event_id, e2.event_id]);
+
+ // Secret rotation: deliveries signed with the previous secret are accepted while it is configured.
+ consumer.server.close();
+ const rotated = createConsumer(loadConfig({ OV_WEBHOOK_SECRET: 'whsec_new', OV_WEBHOOK_SECRET_PREVIOUS: created.secret, OV_DB_PATH: config.dbPath }), {
+ db: consumer.db, handle: (event) => handled.push(event.event_id), log: { log() {}, error() {} },
+ });
+ await new Promise((res) => rotated.server.listen(0, '127.0.0.1', res));
+ const p2 = rotated.server.address().port;
+ const e3 = envelope();
+ assert.equal((await deliver(p2, e3, 3, created.secret)).status, 200);
+ const e4 = envelope();
+ assert.equal((await deliver(p2, e4, 4, 'whsec_new')).status, 200);
+ assert.equal((await deliver(p2, e4, 4, 'whsec_new')).body.duplicate, true);
+ assert.deepEqual(handled, [e1.event_id, e2.event_id, e3.event_id, e4.event_id]);
+
+ // The receipt and the app's own row were written together.
+ const rows = consumer.db.prepare('SELECT event_id FROM received_events ORDER BY seq').all().map((x) => x.event_id);
+ assert.deepEqual(rows, handled);
+
+ // Oversized bodies are refused before parsing.
+ const big = await realFetch(`http://127.0.0.1:${p2}/webhooks/openvibe`, { method: 'POST', body: 'x'.repeat(1024 * 1024 + 10) });
+ assert.equal(big.status, 413);
+
+ // No secret in the logs.
+ assert.ok(!logs.join('\n').includes(created.secret));
+
+ rotated.server.close();
+ consumer.db.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ console.log('webhook-consumer: ok');
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,725 @@
@@ -0,0 +1,725 @@
+{
+ "name": "openvibe-examples",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-examples",
+ "version": "0.1.0",
+ "license": "MIT",
+ "workspaces": [
+ "examples/*"
+ ],
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "examples/browser-app": {
+ "name": "openvibe-example-browser-app",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "examples/chat-bot": {
+ "name": "openvibe-example-chat-bot",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"
+ },
+ "devDependencies": {
+ "ws": "^8.18.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "examples/event-subscriber": {
+ "name": "openvibe-example-event-subscriber",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "examples/media-uploader": {
+ "name": "openvibe-example-media-uploader",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "examples/mod-manifest": {
+ "name": "openvibe-example-mod-manifest",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "examples/node-server-app": {
+ "name": "openvibe-example-node-server-app",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "examples/oauth-app": {
+ "name": "openvibe-example-oauth-app",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "examples/tool-job": {
+ "name": "openvibe-example-tool-job",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "examples/webhook-consumer": {
+ "name": "openvibe-example-webhook-consumer",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "better-sqlite3": "^11.10.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "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/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "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/better-sqlite3": {
+ "version": "11.10.0",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
+ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "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",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "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.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
+ },
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "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": "BSD-3-Clause"
+ },
+ "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/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
+ "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/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
+ },
+ "node_modules/node-abi": {
+ "version": "3.96.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
+ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/openvibe-contracts": {
+ "version": "0.26.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0",
+ "integrity": "sha512-yigHjNzLIyK578V0Weqv2zL6z8PO8ce3HRXPa/61n1XK5Y7pTi28qHMMiO6stboQjTziN1+8wMeByoCF1sGCnw==",
+ "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-example-browser-app": {
+ "resolved": "examples/browser-app",
+ "link": true
+ },
+ "node_modules/openvibe-example-chat-bot": {
+ "resolved": "examples/chat-bot",
+ "link": true
+ },
+ "node_modules/openvibe-example-event-subscriber": {
+ "resolved": "examples/event-subscriber",
+ "link": true
+ },
+ "node_modules/openvibe-example-media-uploader": {
+ "resolved": "examples/media-uploader",
+ "link": true
+ },
+ "node_modules/openvibe-example-mod-manifest": {
+ "resolved": "examples/mod-manifest",
+ "link": true
+ },
+ "node_modules/openvibe-example-node-server-app": {
+ "resolved": "examples/node-server-app",
+ "link": true
+ },
+ "node_modules/openvibe-example-oauth-app": {
+ "resolved": "examples/oauth-app",
+ "link": true
+ },
+ "node_modules/openvibe-example-tool-job": {
+ "resolved": "examples/tool-job",
+ "link": true
+ },
+ "node_modules/openvibe-example-webhook-consumer": {
+ "resolved": "examples/webhook-consumer",
+ "link": true
+ },
+ "node_modules/openvibe-sdk": {
+ "version": "0.2.2",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
+ "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "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/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/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "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/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "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",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
+ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/ws": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
+ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+{
+ "name": "openvibe-examples",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Executable public integration examples for the OpenVibe platform: SDK, scoped credentials and public APIs only.",
+ "license": "MIT",
+ "repository": "github:OpenVibers/OpenVibe.Examples",
+ "workspaces": [
+ "examples/*"
+ ],
+ "scripts": {
+ "test": "node test/run.js"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+'use strict';
+/**
+ * The examples use public surfaces only. Fails on:
+ * - the loopback shared-key header used between first-party services;
+ * - paths under the platform's internal route prefix;
+ * - hard-coded loopback service ports (127.0.0.1:<digits>, localhost:4xxx = the platform's
+ * service port range), except in the local test mocks (test/mock-*.js);
+ * - a secret-looking value in any .env.example.
+ * Scans every file in the repository except node_modules, .git and this file.
+ */
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const ROOT = path.join(__dirname, '..');
+const SELF = __filename;
+const SKIP_DIRS = new Set(['node_modules', '.git']);
+const TEXT = /\.(js|mjs|cjs|json|md|html|css|yml|yaml|txt)$|\.env\.example$|^\.gitignore$/;
+
+function walk(dir, out = []) {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ if (SKIP_DIRS.has(entry.name)) continue;
+ const p = path.join(dir, entry.name);
+ if (entry.isDirectory()) walk(p, out);
+ else if (TEXT.test(entry.name) && p !== SELF && entry.name !== 'package-lock.json') out.push(p);
+ }
+ return out;
+}
+
+const RULES = [
+ { name: 'internal key header', re: new RegExp(['X', 'Internal', 'Key'].join('-'), 'i') },
+ { name: 'internal route', re: new RegExp(`/${'internal'}/`) },
+ { name: 'loopback service port', re: new RegExp(`127\\.0\\.0\\.1:\\d|localhost:4\\d{3}\\b`), allow: (rel) => /(^|\/)test\/mock-[\w-]+\.js$/.test(rel) },
+];
+
+const files = walk(ROOT);
+assert.ok(files.length > 20, 'the scan found the examples');
+const problems = [];
+for (const file of files) {
+ const rel = path.relative(ROOT, file).split(path.sep).join('/');
+ const lines = fs.readFileSync(file, 'utf8').split('\n');
+ lines.forEach((line, i) => {
+ for (const rule of RULES) {
+ if (rule.re.test(line) && !(rule.allow && rule.allow(rel))) problems.push(`${rel}:${i + 1} ${rule.name}: ${line.trim().slice(0, 120)}`);
+ }
+ });
+ if (rel.endsWith('.env.example')) {
+ lines.forEach((line, i) => {
+ const m = line.match(/^\s*(OV_[A-Z_]*(SECRET|TOKEN)[A-Z_]*)\s*=\s*(\S+)/);
+ if (m) problems.push(`${rel}:${i + 1} ${m[1]} has a value in .env.example; leave secrets empty`);
+ });
+ }
+}
+assert.deepEqual(problems, [], `public-surface violations:\n${problems.join('\n')}`);
+
+// The rule itself works (so a green run means something).
+assert.ok(RULES[0].re.test('X-' + 'Internal-Key: k'));
+assert.ok(RULES[1].re.test("path: '/" + "internal/identity/resolve'"));
+assert.ok(RULES[2].re.test('http://127.0.0.1:' + '4300/api'));
+assert.ok(!RULES[2].re.test('`http://127.0.0.1:${port}`'));
+console.log(`public-surface: ok (${files.length} files)`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Runs every example's smoke test (examples/<name>/test/smoke.test.js) and the repository checks
+ * in test/*.test.js, each in its own process. No test needs the network or a running service:
+ * the examples run against openvibe-sdk/testing's mock platform and small local mocks.
+ *
+ * npm test # everything
+ * npm test -- chat oauth # only names containing one of the words
+ */
+const { spawn } = require('node:child_process');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const ROOT = path.join(__dirname, '..');
+const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
+const TIMEOUT_MS = 120000;
+
+const tests = [
+ ...fs.readdirSync(path.join(ROOT, 'examples')).sort()
+ .filter((d) => fs.existsSync(path.join(ROOT, 'examples', d, 'test', 'smoke.test.js')))
+ .map((d) => ({ name: `examples/${d}`, file: path.join(ROOT, 'examples', d, 'test', 'smoke.test.js'), cwd: path.join(ROOT, 'examples', d) })),
+ ...fs.readdirSync(__dirname).filter((f) => f.endsWith('.test.js')).sort()
+ .map((f) => ({ name: `test/${f}`, file: path.join(__dirname, f), cwd: ROOT })),
+].filter((t) => !filters.length || filters.some((w) => t.name.includes(w)));
+
+function runOne(t) {
+ return new Promise((resolve) => {
+ const started = Date.now();
+ const child = spawn(process.execPath, [t.file], { cwd: t.cwd, env: { ...process.env, NODE_ENV: 'test' }, stdio: ['ignore', 'pipe', 'pipe'] });
+ let output = '';
+ child.stdout.on('data', (c) => { output += c; });
+ child.stderr.on('data', (c) => { output += c; });
+ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);
+ child.on('close', (code, signal) => {
+ clearTimeout(timer);
+ resolve({ ...t, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });
+ });
+ });
+}
+
+(async () => {
+ if (!tests.length) { console.error('no tests matched'); process.exit(1); }
+ console.log(`node ${process.version}`);
+ const results = [];
+ for (const t of tests) {
+ const r = await runOne(t);
+ results.push(r);
+ console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.name.padEnd(34)} ${String(r.ms).padStart(6)}ms`);
+ }
+ const failed = results.filter((r) => !r.ok);
+ for (const r of failed) {
+ console.log(`\n-- ${r.name} (exit ${r.code}) --`);
+ console.log(r.output.split('\n').slice(-60).join('\n'));
+ }
+ console.log(`\n${results.length - failed.length}/${results.length} passed`);
+ process.exit(failed.length ? 1 : 0);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+'use strict';
+/**
+ * Every example is copyable on its own: a README (with how to run it against the real platform),
+ * a .env.example naming every variable its code reads, an MIT package.json whose dependencies are
+ * pinned tags (no file: links), and a smoke test that the root runner picks up.
+ */
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const ROOT = path.join(__dirname, '..');
+const EXPECTED = ['browser-app', 'chat-bot', 'event-subscriber', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'tool-job', 'webhook-consumer'];
+const SDK = 'https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2';
+
+const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();
+assert.deepEqual(dirs, EXPECTED, 'the nine charter examples');
+
+function sources(dir) {
+ const out = [];
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ if (e.name === 'node_modules' || e.name === 'test') continue;
+ const p = path.join(dir, e.name);
+ if (e.isDirectory()) out.push(...sources(p));
+ else if (/\.js$/.test(e.name)) out.push(p);
+ }
+ return out;
+}
+
+for (const name of EXPECTED) {
+ const dir = path.join(ROOT, 'examples', name);
+ for (const f of ['README.md', '.env.example', 'package.json', 'test/smoke.test.js']) {
+ assert.ok(fs.existsSync(path.join(dir, f)), `${name}/${f} exists`);
+ }
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
+ assert.equal(pkg.license, 'MIT', `${name}: MIT`);
+ assert.equal(pkg.scripts.test, 'node test/smoke.test.js', `${name}: npm test runs the smoke test`);
+ for (const [dep, spec] of Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })) {
+ assert.ok(!/^(file|link):/.test(spec), `${name}: ${dep} is not a local link`);
+ if (dep === 'openvibe-sdk') assert.equal(spec, SDK, `${name}: openvibe-sdk pinned to v0.2.2`);
+ }
+
+ const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf8');
+ assert.match(readme, /^## Run it against the real platform$/m, `${name}: README says how to run it for real`);
+ assert.match(readme, /^## What it proves$/m, `${name}: README says what it proves`);
+
+ const envExample = fs.readFileSync(path.join(dir, '.env.example'), 'utf8');
+ const declared = new Set([...envExample.matchAll(/^#?\s*(OV_[A-Z_]+)=/gm)].map((m) => m[1]));
+ const used = new Set();
+ for (const file of sources(dir)) {
+ for (const m of fs.readFileSync(file, 'utf8').matchAll(/env\.(OV_[A-Z_]+)/g)) used.add(m[1]);
+ }
+ for (const v of used) assert.ok(declared.has(v), `${name}: ${v} is read by the code but missing from .env.example`);
+}
+
+// The root runner and CI.
+const ci = fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'ci.yml'), 'utf8');
+assert.match(ci, /node-version: 22\.22\.1/);
+assert.match(ci, /npm test/);
+assert.equal(JSON.parse(fs.readFileSync(path.join(ROOT, 'STATUS.json'), 'utf8')).stage, 'alpha');
+assert.match(fs.readFileSync(path.join(ROOT, 'LICENSE'), 'utf8'), /^MIT License/);
+console.log('structure: ok');