openvibers/openvibe.examples Commits

OpenVibe.Examples 0.1.0 (Wave 20): the nine charter examples with smoke tests

OP OpenVibers committed 23 Sept 2026 · 7b687ed49f · 62 files, +4973 −718

browser-app (PKCE in a no-build page, public app, registry from the browser), node-server-app
(client-credentials app token per audience, registry discovery), webhook-consumer (signature on
the raw body, exactly-once inbox, secret rotation), event-subscriber (realtime + pull with a
durable cursor, resume, gap handling), media-uploader (app token into the project namespace),
chat-bot (one configured room, always labelled as a bot), tool-job (idempotent submit, reattach,
SSE resume with Last-Event-ID), mod-manifest (mods.mod-manifest@1 + catalog + compatibility),
oauth-app (authorization code + PKCE, server-side exchange, offline verification).

Every smoke test runs offline against openvibe-sdk/testing's mock platform, plus local mocks for
Chat's /ws/chat and Tools' /api/v1/jobs. Root npm test runs them all with a public-surface check
(no internal key header, no internal routes, no hard-coded loopback service ports) and a
structure check. CI on Node 22.22.1. openvibe-sdk v0.2.2, openvibe-contracts v0.26.0. MIT.
The README lists what does not work against the live platform yet and why.

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..852edca
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -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
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..7fe175c
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,9 @@
++node_modules/
++.env
++.env.webhook
++data/
++out/
++.job-state.json
++*.db
++*.db-wal
++*.db-shm
ddiff --git a/LICENSE b/LICENSE
iindex 0ad25db..ac75a34 100644
---- a/LICENSE
++++ b/LICENSE
@@@ -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.
ddiff --git a/README.md b/README.md
iindex f4df845..e2b050c 100644
---- a/README.md
++++ b/README.md
@@@ -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`.
---
ddiff --git a/STATUS.json b/STATUS.json
iindex 1e897f5..54cfb34 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/examples/browser-app/.env.example b/examples/browser-app/.env.example
nnew file mode 100644
iindex 0000000..2119f63
---- /dev/null
++++ b/examples/browser-app/.env.example
@@@ -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
ddiff --git a/examples/browser-app/README.md b/examples/browser-app/README.md
nnew file mode 100644
iindex 0000000..f90006d
---- /dev/null
++++ b/examples/browser-app/README.md
@@@ -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`).
ddiff --git a/examples/browser-app/lib/sdk-bundle.js b/examples/browser-app/lib/sdk-bundle.js
nnew file mode 100644
iindex 0000000..46cef85
---- /dev/null
++++ b/examples/browser-app/lib/sdk-bundle.js
@@@ -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 };
ddiff --git a/examples/browser-app/package.json b/examples/browser-app/package.json
nnew file mode 100644
iindex 0000000..a94a5b2
---- /dev/null
++++ b/examples/browser-app/package.json
@@@ -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"
++ }
++}
ddiff --git a/examples/browser-app/public/app.js b/examples/browser-app/public/app.js
nnew file mode 100644
iindex 0000000..aebab2b
---- /dev/null
++++ b/examples/browser-app/public/app.js
@@@ -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); });
++ });
++}());
ddiff --git a/examples/browser-app/public/index.html b/examples/browser-app/public/index.html
nnew file mode 100644
iindex 0000000..d074b47
---- /dev/null
++++ b/examples/browser-app/public/index.html
@@@ -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>
ddiff --git a/examples/browser-app/server.js b/examples/browser-app/server.js
nnew file mode 100644
iindex 0000000..bae293d
---- /dev/null
++++ b/examples/browser-app/server.js
@@@ -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 };
ddiff --git a/examples/browser-app/test/smoke.test.js b/examples/browser-app/test/smoke.test.js
nnew file mode 100644
iindex 0000000..176879f
---- /dev/null
++++ b/examples/browser-app/test/smoke.test.js
@@@ -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); });
ddiff --git a/examples/chat-bot/.env.example b/examples/chat-bot/.env.example
nnew file mode 100644
iindex 0000000..4f500b4
---- /dev/null
++++ b/examples/chat-bot/.env.example
@@@ -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
ddiff --git a/examples/chat-bot/README.md b/examples/chat-bot/README.md
nnew file mode 100644
iindex 0000000..d993d64
---- /dev/null
++++ b/examples/chat-bot/README.md
@@@ -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.
ddiff --git a/examples/chat-bot/bot.js b/examples/chat-bot/bot.js
nnew file mode 100644
iindex 0000000..4ae0193
---- /dev/null
++++ b/examples/chat-bot/bot.js
@@@ -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 };
ddiff --git a/examples/chat-bot/package.json b/examples/chat-bot/package.json
nnew file mode 100644
iindex 0000000..0c4315b
---- /dev/null
++++ b/examples/chat-bot/package.json
@@@ -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"
++ }
++}
ddiff --git a/examples/chat-bot/test/mock-chat-server.js b/examples/chat-bot/test/mock-chat-server.js
nnew file mode 100644
iindex 0000000..98e033a
---- /dev/null
++++ b/examples/chat-bot/test/mock-chat-server.js
@@@ -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 };
ddiff --git a/examples/chat-bot/test/smoke.test.js b/examples/chat-bot/test/smoke.test.js
nnew file mode 100644
iindex 0000000..3983b2d
---- /dev/null
++++ b/examples/chat-bot/test/smoke.test.js
@@@ -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); });
ddiff --git a/examples/event-subscriber/.env.example b/examples/event-subscriber/.env.example
nnew file mode 100644
iindex 0000000..73678ed
---- /dev/null
++++ b/examples/event-subscriber/.env.example
@@@ -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=
ddiff --git a/examples/event-subscriber/README.md b/examples/event-subscriber/README.md
nnew file mode 100644
iindex 0000000..47cfdfa
---- /dev/null
++++ b/examples/event-subscriber/README.md
@@@ -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.
ddiff --git a/examples/event-subscriber/package.json b/examples/event-subscriber/package.json
nnew file mode 100644
iindex 0000000..0905398
---- /dev/null
++++ b/examples/event-subscriber/package.json
@@@ -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"
++ }
++}
ddiff --git a/examples/event-subscriber/subscriber.js b/examples/event-subscriber/subscriber.js
nnew file mode 100644
iindex 0000000..7b3b266
---- /dev/null
++++ b/examples/event-subscriber/subscriber.js
@@@ -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 };
ddiff --git a/examples/event-subscriber/test/smoke.test.js b/examples/event-subscriber/test/smoke.test.js
nnew file mode 100644
iindex 0000000..e956143
---- /dev/null
++++ b/examples/event-subscriber/test/smoke.test.js
@@@ -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); });
ddiff --git a/examples/media-uploader/.env.example b/examples/media-uploader/.env.example
nnew file mode 100644
iindex 0000000..89c540d
---- /dev/null
++++ b/examples/media-uploader/.env.example
@@@ -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=
ddiff --git a/examples/media-uploader/README.md b/examples/media-uploader/README.md
nnew file mode 100644
iindex 0000000..c06176d
---- /dev/null
++++ b/examples/media-uploader/README.md
@@@ -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.
ddiff --git a/examples/media-uploader/package.json b/examples/media-uploader/package.json
nnew file mode 100644
iindex 0000000..bf166a2
---- /dev/null
++++ b/examples/media-uploader/package.json
@@@ -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"
++ }
++}
ddiff --git a/examples/media-uploader/test/smoke.test.js b/examples/media-uploader/test/smoke.test.js
nnew file mode 100644
iindex 0000000..fac9d87
---- /dev/null
++++ b/examples/media-uploader/test/smoke.test.js
@@@ -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); });
ddiff --git a/examples/media-uploader/upload.js b/examples/media-uploader/upload.js
nnew file mode 100644
iindex 0000000..9175906
---- /dev/null
++++ b/examples/media-uploader/upload.js
@@@ -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 };
ddiff --git a/examples/mod-manifest/.env.example b/examples/mod-manifest/.env.example
nnew file mode 100644
iindex 0000000..c9210b7
---- /dev/null
++++ b/examples/mod-manifest/.env.example
@@@ -0,0 +1,2 @@
++# Only for "node validate.js mod.json --registry" (the public registry needs no credentials).
++OV_NETWORK_URL=https://openvibe.network
ddiff --git a/examples/mod-manifest/README.md b/examples/mod-manifest/README.md
nnew file mode 100644
iindex 0000000..6836e4f
---- /dev/null
++++ b/examples/mod-manifest/README.md
@@@ -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.
ddiff --git a/examples/mod-manifest/mod.json b/examples/mod-manifest/mod.json
nnew file mode 100644
iindex 0000000..e8b1c59
---- /dev/null
++++ b/examples/mod-manifest/mod.json
@@@ -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"
++}
ddiff --git a/examples/mod-manifest/package.json b/examples/mod-manifest/package.json
nnew file mode 100644
iindex 0000000..def2a17
---- /dev/null
++++ b/examples/mod-manifest/package.json
@@@ -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"
++ }
++}
ddiff --git a/examples/mod-manifest/test/smoke.test.js b/examples/mod-manifest/test/smoke.test.js
nnew file mode 100644
iindex 0000000..9efc132
---- /dev/null
++++ b/examples/mod-manifest/test/smoke.test.js
@@@ -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); });
ddiff --git a/examples/mod-manifest/validate.js b/examples/mod-manifest/validate.js
nnew file mode 100644
iindex 0000000..0c2ea1d
---- /dev/null
++++ b/examples/mod-manifest/validate.js
@@@ -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 };
ddiff --git a/examples/node-server-app/.env.example b/examples/node-server-app/.env.example
nnew file mode 100644
iindex 0000000..8ec8af3
---- /dev/null
++++ b/examples/node-server-app/.env.example
@@@ -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
ddiff --git a/examples/node-server-app/README.md b/examples/node-server-app/README.md
nnew file mode 100644
iindex 0000000..65963d1
---- /dev/null
++++ b/examples/node-server-app/README.md
@@@ -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).
ddiff --git a/examples/node-server-app/package.json b/examples/node-server-app/package.json
nnew file mode 100644
iindex 0000000..e031850
---- /dev/null
++++ b/examples/node-server-app/package.json
@@@ -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"
++ }
++}
ddiff --git a/examples/node-server-app/server.js b/examples/node-server-app/server.js
nnew file mode 100644
iindex 0000000..9c52487
---- /dev/null
++++ b/examples/node-server-app/server.js
@@@ -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 };
ddiff --git a/examples/node-server-app/test/smoke.test.js b/examples/node-server-app/test/smoke.test.js
nnew file mode 100644
iindex 0000000..d83e63c
---- /dev/null
++++ b/examples/node-server-app/test/smoke.test.js
@@@ -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); });
ddiff --git a/examples/oauth-app/.env.example b/examples/oauth-app/.env.example
nnew file mode 100644
iindex 0000000..e416617
---- /dev/null
++++ b/examples/oauth-app/.env.example
@@@ -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
ddiff --git a/examples/oauth-app/README.md b/examples/oauth-app/README.md
nnew file mode 100644
iindex 0000000..e629f2e
---- /dev/null
++++ b/examples/oauth-app/README.md
@@@ -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).
ddiff --git a/examples/oauth-app/package.json b/examples/oauth-app/package.json
nnew file mode 100644
iindex 0000000..a78efa1
---- /dev/null
++++ b/examples/oauth-app/package.json
@@@ -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"
++ }
++}
ddiff --git a/examples/oauth-app/server.js b/examples/oauth-app/server.js
nnew file mode 100644
iindex 0000000..3a0b5fc
---- /dev/null
++++ b/examples/oauth-app/server.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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 };
ddiff --git a/examples/oauth-app/test/smoke.test.js b/examples/oauth-app/test/smoke.test.js
nnew file mode 100644
iindex 0000000..17c9c27
---- /dev/null
++++ b/examples/oauth-app/test/smoke.test.js
@@@ -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); });
ddiff --git a/examples/tool-job/.env.example b/examples/tool-job/.env.example
nnew file mode 100644
iindex 0000000..0a5f73d
---- /dev/null
++++ b/examples/tool-job/.env.example
@@@ -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
ddiff --git a/examples/tool-job/README.md b/examples/tool-job/README.md
nnew file mode 100644
iindex 0000000..fbfdb51
---- /dev/null
++++ b/examples/tool-job/README.md
@@@ -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.
ddiff --git a/examples/tool-job/package.json b/examples/tool-job/package.json
nnew file mode 100644
iindex 0000000..a7598b8
---- /dev/null
++++ b/examples/tool-job/package.json
@@@ -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"
++ }
++}
ddiff --git a/examples/tool-job/run-job.js b/examples/tool-job/run-job.js
nnew file mode 100644
iindex 0000000..c810ae7
---- /dev/null
++++ b/examples/tool-job/run-job.js
@@@ -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 };
ddiff --git a/examples/tool-job/test/mock-tools-server.js b/examples/tool-job/test/mock-tools-server.js
nnew file mode 100644
iindex 0000000..8579694
---- /dev/null
++++ b/examples/tool-job/test/mock-tools-server.js
@@@ -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 };
ddiff --git a/examples/tool-job/test/smoke.test.js b/examples/tool-job/test/smoke.test.js
nnew file mode 100644
iindex 0000000..6504339
---- /dev/null
++++ b/examples/tool-job/test/smoke.test.js
@@@ -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); });
ddiff --git a/examples/webhook-consumer/.env.example b/examples/webhook-consumer/.env.example
nnew file mode 100644
iindex 0000000..b9255f8
---- /dev/null
++++ b/examples/webhook-consumer/.env.example
@@@ -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=
ddiff --git a/examples/webhook-consumer/README.md b/examples/webhook-consumer/README.md
nnew file mode 100644
iindex 0000000..a2c65c3
---- /dev/null
++++ b/examples/webhook-consumer/README.md
@@@ -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.
ddiff --git a/examples/webhook-consumer/package.json b/examples/webhook-consumer/package.json
nnew file mode 100644
iindex 0000000..6659944
---- /dev/null
++++ b/examples/webhook-consumer/package.json
@@@ -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"
++ }
++}
ddiff --git a/examples/webhook-consumer/server.js b/examples/webhook-consumer/server.js
nnew file mode 100644
iindex 0000000..a77fb6a
---- /dev/null
++++ b/examples/webhook-consumer/server.js
@@@ -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 };
ddiff --git a/examples/webhook-consumer/subscribe.js b/examples/webhook-consumer/subscribe.js
nnew file mode 100644
iindex 0000000..2886bab
---- /dev/null
++++ b/examples/webhook-consumer/subscribe.js
@@@ -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 };
ddiff --git a/examples/webhook-consumer/test/smoke.test.js b/examples/webhook-consumer/test/smoke.test.js
nnew file mode 100644
iindex 0000000..c36e6af
---- /dev/null
++++ b/examples/webhook-consumer/test/smoke.test.js
@@@ -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); });
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..72604dd
---- /dev/null
++++ b/package-lock.json
@@@ -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
++ }
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..9333843
---- /dev/null
++++ b/package.json
@@@ -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"
++ }
++}
ddiff --git a/test/public-surface.test.js b/test/public-surface.test.js
nnew file mode 100644
iindex 0000000..254929a
---- /dev/null
++++ b/test/public-surface.test.js
@@@ -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)`);
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..593907e
---- /dev/null
++++ b/test/run.js
@@@ -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);
++})();
ddiff --git a/test/structure.test.js b/test/structure.test.js
nnew file mode 100644
iindex 0000000..05cbd4e
---- /dev/null
++++ b/test/structure.test.js
@@@ -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');