openvibers/openvibe.sdk Commits

openvibe-sdk 0.1.0: core client, auth, registry, identity, modules, events, realtime, media, community and a mock platform

OP OpenVibers committed 23 Sept 2026 · c5d6d281c8 · 73 files, +5175 −728

One package with subpath exports, CommonJS plus ESM entry points, no runtime dependencies, no
build step. Every call has a deadline, retries only when repeating is safe (idempotent methods
or an Idempotency-Key), maps RFC 9457 problems to OpenVibeError, and propagates traceparent.
Service origins and the contracts version come from /.well-known/openvibe.

Browser entry points carry no secrets and no Node code (scanned by a test); identity, events
and testing are server-only ("browser": null). Hand-written .d.ts per subpath, contract types
checked against OpenVibe.Contracts v0.6.0. License changes to MIT (library). STATUS alpha, W2.

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..c4db2d5
---- /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 # production services run Node 22
++ # types.test.js compares types/contracts.d.ts with the pinned Contracts release
++ - run: git clone --depth 1 --branch v0.6.0 https://github.com/OpenVibers/OpenVibe.Contracts.git ../OpenVibe.Contracts
++ - run: npm test # includes the pack test (npm pack + install into a temp project)
++ - run: node scripts/esm.js --check
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..2d98f97
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,2 @@
++node_modules/
++*.tgz
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
nnew file mode 100644
iindex 0000000..fc0cfa8
---- /dev/null
++++ b/CHANGELOG.md
@@@ -0,0 +1,40 @@
++# Changelog
++
++All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
++release may change an API and says so here.
++
++## 0.1.0 (2026-09-22)
++
++First release (roadmap Wave 2, implementation plan §3.2). Built and tested against
++openvibe-contracts `>=0.5.0 <1.0.0` (v0.6.0).
++
++- `openvibe-sdk/core`: `createClient()` with per-attempt timeouts and a whole-call deadline;
++ retries only for idempotent methods or requests carrying an `Idempotency-Key` (generated for
++ mutations when `retries > 0`, opt out with `idempotencyKey: false`); `Retry-After`; one token
++ refresh on 401; RFC 9457 problems as `OpenVibeError` (`code`, `status`, `detail`, `requestId`,
++ `traceId`); W3C `traceparent` + `X-OpenVibe-Request-Id` on every call, `withContext()` /
++ `fromRequest()` to continue an incoming trace; `paginate()` async iterator; `discover()` reads
++ `/.well-known/openvibe`, caches service origins and checks the contracts version.
++- `openvibe-sdk/auth`: server `createServiceTokenClient()` (client credentials, cache per audience
++ until 60 s before expiry, shared in-flight request), `verifyUserToken()` (offline RS256 against
++ the Network JWKS), `exchangeCode()` / `refreshUserToken()`; browser PKCE helpers
++ (`startAuthorization()`, `buildAuthorizeUrl()`, `readCallback()`).
++- `openvibe-sdk/registry`: services, service, capabilities, capability, namespaces, contracts,
++ topics, domain.
++- `openvibe-sdk/identity` (server): `resolve()`, `resolveBatch()` (split at 500).
++- `openvibe-sdk/modules`: user get/put/delete/list/publicGet with `If-Match`, `update()`
++ read-modify-write retrying on 412; service `forSubject.get/put/update`.
++- `openvibe-sdk/events` (server): `publish()` fills `event_id`, `timestamp`, `source`, `version`,
++ `payload`, `trace_id`; `pull()` / `iterate()` with cursor and gap reporting; checkpoints;
++ subscriptions; deliveries/replay; `verifyDelivery()`, `signDelivery()`, `parseDelivery()`.
++- `openvibe-sdk/realtime`: `subscribe()` over SSE (EventSource or fetch stream), resume from the
++ last seq, dedupe, `onGap`.
++- `openvibe-sdk/media`: files upload/list/iterate/get/delete on `/api/v1/:app/files`, public URL
++ helpers.
++- `openvibe-sdk/community`: pastes list/iterate/get/create (text or screenshot)/update/delete/fork/
++ like/copy/versions/byUser/config and paste comments, with `actingSubject`, `origin: 'ai'`,
++ `sourceRef` and `staff` for services.
++- `openvibe-sdk/testing`: `createMockPlatform()`, an in-process fake Network (token endpoint,
++ JWKS, discovery, registry, modules, identity), Events (publish, pull, subscriptions, SSE) and
++ Media files.
++- Hand-written `.d.ts` per subpath; ESM entry points; MIT license.
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 abed655..b510eb9 100644
---- a/README.md
++++ b/README.md
@@@ -2,73 +2,195 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** placeholder — planning only, no runnable code yet.
--**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2.
--**License:** AGPL-3.0 (same as every OpenVibe service).
++**Status:** alpha, v0.1.0 (roadmap Wave 2). Runs and is tested against local stub servers and the built-in mock platform. No OpenVibe service or app uses it in production yet.
++**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
++**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
++
++**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
--## Purpose
++```bash
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.1.0
++```
--Typed clients generated from OpenVibe.Contracts plus hand-written behaviour for auth, retries, idempotency, pagination, realtime and trace propagation, so no consumer has to know internal route layouts.
++It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. `openvibe-contracts` is an optional peer dependency pinned to v0.6.0. The SDK doesn't need it at runtime and copies the contract types it uses.
--## Owns
++## Quick start
--- `@openvibe/sdk-core|auth|registry|events|realtime|media|chat|community|live|billing|games|jobs` packages
--- version negotiation and feature detection
--- mock/local adapters for development
--
--## Does not own
--
--- service implementations
--- UI components (see OpenVibe.Shared)
--
--## Planned surfaces
--
--- separate browser and server entry points, tree-shakeable
--- deadline/timeouts, retry only where semantics allow, `Idempotency-Key` helper
--- typed cursors/pagination and standard errors
--- automatic `traceparent` propagation
--
--## Data (authority tables / families)
--
--- none (artifact repository)
--
--## Capabilities and events
--
--- typed wrappers for every public capability
--
--Events: n/a
--
--## Depends on
--
--- OpenVibe.Contracts
--- OpenVibe.Network (OAuth2/OIDC, service principals)
--
--## Acceptance (must be true before "done")
--
--- an external example app authenticates, discovers Media from the registry and uploads an object with only the SDK and a capability grant
--- no secret-bearing code ships in browser bundles
--
--## Bootstrap / extraction source
--
--Replaces the hand-written per-repo clients (Live's `media-client`, Community's proxies, Tools' fetch helpers).
--
--## 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):
--
--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.
--
--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.
++### Browser app (signed-in user)
++
++```js
++import { createClient } from 'openvibe-sdk/core';
++import { startAuthorization, readCallback } from 'openvibe-sdk/auth'; // browser build: PKCE only
++import { createCommunityClient } from 'openvibe-sdk/community';
++import { subscribe } from 'openvibe-sdk/realtime';
++
++// Sign in: send the browser to the Network with a PKCE challenge.
++const { url, state, codeVerifier } = await startAuthorization({ clientId: 'my-app', redirectUri: 'https://my.app/auth/callback' });
++sessionStorage.setItem('ov_pkce', JSON.stringify({ state, codeVerifier }));
++location.assign(url);
++
++// On /auth/callback: check state, then hand the code and verifier to YOUR server (see below).
++const saved = JSON.parse(sessionStorage.getItem('ov_pkce'));
++const { code } = readCallback(location.href, { expectedState: saved.state });
++await fetch('/auth/exchange', { method: 'POST', body: JSON.stringify({ code, codeVerifier: saved.codeVerifier }) });
++
++// Call services as the signed-in person (Network session cookie).
++const client = createClient({ credentials: 'include' });
++const community = createCommunityClient(client);
++const { pastes } = await community.pastes.list({ limit: 20 });
++
++// Live updates, resumable across reloads.
++const sub = subscribe(['live.stream.*'], (event, { seq }) => render(event), {
++ client, lastEventId: localStorage.getItem('seq'), onGap: () => refetchEverything(),
++});
++```
++
++**The token exchange happens on your server.** Every OpenVibe OAuth client is confidential: it has a client secret, and a secret must never reach a browser. The browser entry points contain no exchange code. A test scans every file a browser bundle can reach and fails on `client_secret`, `node:` imports, `require('crypto')` or `process.env`.
++
++### Server service (service principal)
++
++```js
++const { createClient } = require('openvibe-sdk/core');
++const { createServiceTokenClient, exchangeCode, verifyUserToken } = require('openvibe-sdk/auth');
++const { createEventsClient } = require('openvibe-sdk/events');
++const { createIdentityClient } = require('openvibe-sdk/identity');
++
++const tokens = createServiceTokenClient({ clientId: 'live', clientSecret: process.env.OV_CLIENT_SECRET });
++const client = createClient({
++ tokenProvider: tokens, // one cached token per audience (openvibe.events, openvibe.media, …)
++ baseUrls: { network: 'http://127.0.0.1:4000', events: 'http://127.0.0.1:4300' }, // same-host services; others come from discovery
++});
++
++// Continue the incoming request's trace on every outbound call.
++app.use((req, _res, next) => { req.ov = client.fromRequest(req); next(); });
++
++const events = createEventsClient(client, { source: 'live' });
++await events.publish({ event_type: 'live.stream.started', actor: { type: 'service', id: 'live' }, subject: { type: 'stream', id: '12' } });
++
++const who = await createIdentityClient(client).resolve({ system: 'live', id: 123 }); // -> { subject, username, … } | null
++
++// The browser's /auth/exchange from above:
++const t = await exchangeCode({ code, codeVerifier, redirectUri, clientId: 'my-app', clientSecret: process.env.OV_OAUTH_SECRET });
++const claims = await verifyUserToken(t.access_token, { jwks: 'https://openvibe.network/api/.well-known/jwks', audience: 'openvibe.network' });
++claims.subject_id; // usr_…
++```
++
++### External developer (an app outside the network)
++
++An app with only a client id, a secret and a capability grant (for example `media.object.upload` for its own namespace) authenticates, finds Media through the registry and uploads a file:
++
++```js
++const sdk = require('openvibe-sdk');
++
++const client = sdk.createClient({
++ tokenProvider: sdk.auth.createServiceTokenClient({ clientId: 'my-app', clientSecret: process.env.OV_CLIENT_SECRET }),
++});
++await client.discover(); // origins + contracts version from https://openvibe.network/.well-known/openvibe
++const media = sdk.media.createMediaClient(client, { app: 'my-app' });
++const file = await media.upload(fs.readFileSync('logo.png'), { filename: 'logo.png', contentType: 'image/png' });
++file.public_url; // https://openvibe.media/f/<key>
++```
++
++`test/testing.test.js` runs this flow end to end against `openvibe-sdk/testing`.
++
++## API
++
++| Subpath | Where | What |
++|---|---|---|
++| `openvibe-sdk/core` | both | `createClient()`, `OpenVibeError`, `paginate()`, `offsetPager()`, trace and id helpers, `CONTRACTS_RANGE` |
++| `openvibe-sdk/auth` | server (browser build: PKCE only) | `createServiceTokenClient()`, `verifyUserToken()`, `exchangeCode()`, `refreshUserToken()`; `startAuthorization()`, `buildAuthorizeUrl()`, `createPkcePair()`, `pkceChallenge()`, `readCallback()` |
++| `openvibe-sdk/registry` | both | `createRegistryClient(client)`: `services({status})`, `service(id)`, `capabilities({owner})`, `capability(id)`, `namespaces()`, `contracts()`, `topics()`, `domain(host)`, `descriptor()` |
++| `openvibe-sdk/identity` | server | `createIdentityClient(client)`: `resolve({subjectId} \| {system,type,id})`, `resolveBatch({subjectIds} \| {system,type,ids})` |
++| `openvibe-sdk/modules` | both | `createModulesClient(client)`: `get`, `put(ns, data, {revision})`, `delete`, `list`, `update(ns, fn)`, `publicGet`; `forSubject.get/put/update` for services |
++| `openvibe-sdk/events` | server | `createEventsClient(client, {source})`: `publish`, `prepare`, `pull`, `iterate`, `get`, `get/setCheckpoint`, `subscriptions.create/list/get/disable/enable` (`subscribe`), `deliveries`, `replay`; `verifyDelivery`, `signDelivery`, `parseDelivery` |
++| `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)` |
++| `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers |
++| `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, `as(subject)` |
++| `openvibe-sdk/testing` | Node | `createMockPlatform()`: fake Network, Events and Media on an in-process `fetch` |
++
++Server-only subpaths are declared `"browser": null` in the exports map, so a bundler refuses them in a browser build and doesn't ship them by accident.
++
++### How every call behaves (`core`)
++
++- **Where it goes.** Name a `service` and a `path`. The origin comes from `baseUrls[service]`, or from the registry: `discover()` reads `<network>/.well-known/openvibe` and caches it for 5 minutes, and concurrent callers share one fetch. Use `baseUrls` for host-internal addresses such as `/internal/*` routes on `127.0.0.1`.
++- **Version negotiation.** The descriptor includes the contracts release the network runs. `discovery.compatible` says whether it falls within `CONTRACTS_RANGE` (`>=0.5.0 <1.0.0` for this release). By default an out-of-range version logs one warning. With `strictContracts: true` it throws `sdk.incompatible_contracts`. `client.supports(id)` checks whether a service is registered and running.
++- **Deadlines.** `timeoutMs` (default 10 s) applies to each attempt and covers reading the body. `deadlineMs` (default 30 s) bounds the whole call, retries included. An `AbortSignal` cancels the call.
++- **Retries.** A call is retried (default `retries: 2`, exponential backoff with jitter, `Retry-After` honoured) on network errors, timeouts, 408, 425, 429, 502, 503 and 504, and only when repeating it is safe:
++ - GET, HEAD, OPTIONS, PUT and DELETE are always safe to repeat.
++ - Any other method is retried only with an `Idempotency-Key`. When `retries > 0`, the client generates one for each mutation (`idem_<ULID>`) and sends the same key on every attempt.
++ - `idempotencyKey: false` turns this off for writes the server does not dedupe. The Community wrappers do that for create, fork, like, copy and comment.
++ - `idempotent: true` marks a mutation the server dedupes by itself. Events publishing does, because Events dedupes on `event_id`.
++
++ A 401 invalidates the token once and repeats the call with a fresh one.
++- **Errors.** Every failure is an `OpenVibeError` with `code`, `status`, `title`, `detail`, `requestId`, `traceId`, `errors` and `problem`:
++ - RFC 9457 problems keep their `code`.
++ - OAuth errors become their `error` (`invalid_client`).
++ - Legacy `{ error: 'text' }` bodies become `http.<status>`.
++ - Failures with no HTTP response use `sdk.timeout`, `sdk.deadline_exceeded`, `sdk.aborted`, `sdk.network_error` or `sdk.unknown_service`.
++- **Tracing.** Every call sends `traceparent` and `X-OpenVibe-Request-Id`. The request id stays the same across retries. `client.withContext({ traceparent, requestId })` or `client.fromRequest(req)` continues the caller's trace with a new span. The `traceparent` option also accepts a getter, for example one backed by AsyncLocalStorage.
++- **Pagination.** `paginate(fetchPage, { cursor })` is an async iterator over `{ items, next }` pages. The `iterate()` methods build on it: Events uses `after_seq`, and Media and Community use offsets.
++
++### Auth details
++
++- `createServiceTokenClient()` follows the rules of openvibe-contracts `serviceAuth.createTokenClient()` but needs no dependencies. It sends `POST /oauth/token` with `grant_type=client_credentials`, `audience` and an optional `scope`, which can be a string, an array, or a map from audience to scope. It keeps one cached token per audience until 60 s before expiry and shares one in-flight request. As a `tokenProvider`, each call gets a token for the audience of the service it calls: `openvibe.<service>` by default, or the value in `audiences` if you set one.
++- `verifyUserToken(token, { jwks, issuer, audience })` checks a token offline:
++ - It accepts RS256 only. `alg: none`, HS256 and anything else fail as `token.malformed` before any key is used.
++ - It checks the signature against the JWKS document or URL. A URL is cached for 6 h and fetched again when a token carries an unknown `kid`. It also accepts the Network's legacy `public_key` field or a PEM string.
++ - It checks `exp`, `nbf` and `iat` (30 s clock skew), then `iss` and `aud`.
++ - It rejects service-principal tokens (`token.not_user`).
++
++ It returns the claims, including `subject_id`.
++- PKCE follows RFC 7636 S256. The verifier is 64 characters from the unreserved set, and the challenge is `BASE64URL(SHA-256(verifier))`. See the gaps below for what the Network does with it today.
++
++### Testing your app
++
++```js
++const { createMockPlatform } = require('openvibe-sdk/testing');
++const platform = createMockPlatform({
++ clients: { 'my-app': { secret: 's', grants: [{ capability: 'media.object.upload', audience: 'openvibe.media', namespaces: ['my-app'] }] } },
++ mediaApps: { 'my-app': {} },
++ users: [{ username: 'ana' }],
++});
++const client = createClient({ fetch: platform.fetch, tokenProvider: createServiceTokenClient({ clientId: 'my-app', clientSecret: 's', fetch: platform.fetch }) });
++```
++
++The mock answers at the real public origins with real RS256 tokens and checks audience, capability and namespace. It covers:
++
++- **Network:** discovery, `/oauth/token` (client credentials, authorization code with PKCE, refresh), the JWKS, the registry, `/api/modules` and `/internal/modules`, and `/internal/identity`.
++- **Events:** publish with `event_id` dedupe, pull, checkpoints, subscriptions, and `/realtime/stream` SSE with `Last-Event-ID`.
++- **Media:** the files API.
++
++Helpers: `signUserToken()`, `authorize()`, `publishEvent()`, `dropRealtime()`, and `stats` and `state` for assertions. It is a fake. It has no persistence and no delivery worker, and its visibility rules are simplified.
++
++## Versioning
++
++- `openvibe-sdk` follows semver. While it is 0.x, a minor release may break an API, and [CHANGELOG.md](CHANGELOG.md) says so. Pin a tag.
++- Each release states the openvibe-contracts range it was tested against (`CONTRACTS_RANGE`), and `discover()` checks it at runtime. Contract types are copied into `types/contracts.d.ts`. `test/types.test.js` fails if they differ from the pinned Contracts release. CI clones `OpenVibe.Contracts` at the pinned tag for this check.
++- A new public capability ships as a minor release: first the contract, then the wrapper here, then the service's route. Removing a wrapper is a major release, after the capability's deprecation window in Contracts has passed.
++
++## Not wrapped yet (intentionally)
++
++- **Community comments on other content, the forum and Pulse** are being built in Community right now. Only paste comments are wrapped. The rest is a TODO in `src/community.js` until those APIs settle.
++- **Media v2 objects** (`med_` ids, ADR-006) are being added to Media right now. This release wraps only the v1 files API that exists on Media's `main`, plus the public URL helpers. VOD, clip, thumbnail and admin-storage routes are not wrapped because apps reach them only through Live and Media's own servers. Media's own paste API is not wrapped because pastes moved to Community (ADR-011).
++- **Media from the browser:** Media refuses user JWTs on `/api/v1/:app/files`, so a browser uploads through its own app server. That server holds the app key and names the user with `actingUserId`.
++- **PKCE enforcement:** the SDK sends `code_challenge` and `code_verifier`, but the Network's `/oauth/authorize` and `/oauth/token` don't verify them yet. The mock platform does. Security today rests on the client secret, which never leaves the server.
++- **Network coins, notifications and legacy-map writes** are internal service-to-service routes with no public capability, so they are not wrapped. Staff-only paste routes are also internal: admin stats, bulk, censor and the AI pass.
++- **Chat, Live, Billing, Games and Jobs** from the original charter come when those services publish their capabilities in Contracts.
++- **Realtime over WebSocket and presence** don't exist in Events yet (ADR-005). SSE is the only transport.
++
++## Development
++
++```bash
++npm test # every test/*.test.js: stub servers + mock platform, then npm pack + install
++fnm exec --using=22.22.1 npm test # the Node version production runs
++node scripts/esm.js # regenerate esm/*.mjs after changing a module's exports
++```
++
++Style: CommonJS, 4-space indent, single quotes, semicolons. Browser-safe files use only `fetch`, `Headers`, `FormData`, `Blob`, `URL`, `TextEncoder`/`TextDecoder` and `globalThis.crypto` (Web Crypto). Server-only files may use `node:` modules and are marked `"browser": null` in `package.json`.
++
++## Owns / does not own
++
++Owns the supported client surface, version negotiation and feature detection, and the mock adapters for development. It doesn't own service implementations, and it doesn't own UI components (OpenVibe.Shared). It replaces the hand-written per-repo clients: Live's `media-client`, Community's proxies and Tools' fetch helpers.
---
ddiff --git a/STATUS.json b/STATUS.json
iindex e08802d..a72fe49 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -1,7 +1,9 @@
{
"repository": "OpenVibers/OpenVibe.SDK",
-- "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": "W2",
++ "version": "0.1.0",
++ "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
++ "updated": "2026-09-22"
}
ddiff --git a/browser.js b/browser.js
nnew file mode 100644
iindex 0000000..9b4c5c1
---- /dev/null
++++ b/browser.js
@@@ -0,0 +1,16 @@
++'use strict';
++/**
++ * openvibe-sdk (browser entry): core plus the browser-safe families. No server-only code: no
++ * service-token client, no JWT verification, no events publishing, no identity lookups.
++ */
++const core = require('./src/core');
++
++module.exports = {
++ ...core,
++ auth: require('./src/auth/browser'),
++ registry: require('./src/registry'),
++ modules: require('./src/modules'),
++ realtime: require('./src/realtime'),
++ media: require('./src/media'),
++ community: require('./src/community'),
++};
ddiff --git a/esm/auth-browser.mjs b/esm/auth-browser.mjs
nnew file mode 100644
iindex 0000000..059ea99
---- /dev/null
++++ b/esm/auth-browser.mjs
@@@ -0,0 +1,8 @@
++// Generated by scripts/esm.js from src/auth/browser.js. Do not edit.
++import cjs from '../src/auth/browser.js';
++
++export const {
++ createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,
++ readCallback, base64url,
++} = cjs;
++export default cjs;
ddiff --git a/esm/auth.mjs b/esm/auth.mjs
nnew file mode 100644
iindex 0000000..54809be
---- /dev/null
++++ b/esm/auth.mjs
@@@ -0,0 +1,8 @@
++// Generated by scripts/esm.js from src/auth/index.js. Do not edit.
++import cjs from '../src/auth/index.js';
++
++export const {
++ createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,
++ readCallback, base64url, createServiceTokenClient, verifyUserToken, exchangeCode, refreshUserToken,
++} = cjs;
++export default cjs;
ddiff --git a/esm/community.mjs b/esm/community.mjs
nnew file mode 100644
iindex 0000000..2f298a2
---- /dev/null
++++ b/esm/community.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/community.js. Do not edit.
++import cjs from '../src/community.js';
++
++export const {
++ createCommunityClient, actingHeaders,
++} = cjs;
++export default cjs;
ddiff --git a/esm/core.mjs b/esm/core.mjs
nnew file mode 100644
iindex 0000000..dff1332
---- /dev/null
++++ b/esm/core.mjs
@@@ -0,0 +1,9 @@
++// Generated by scripts/esm.js from src/core/index.js. Do not edit.
++import cjs from '../src/core/index.js';
++
++export const {
++ createClient, OpenVibeError, isOpenVibeError, paginate, offsetPager, parseTraceparent,
++ startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
++ satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION,
++} = cjs;
++export default cjs;
ddiff --git a/esm/events.mjs b/esm/events.mjs
nnew file mode 100644
iindex 0000000..a02d50f
---- /dev/null
++++ b/esm/events.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/events.js. Do not edit.
++import cjs from '../src/events.js';
++
++export const {
++ createEventsClient, verifyDelivery, signDelivery, parseDelivery,
++} = cjs;
++export default cjs;
ddiff --git a/esm/identity.mjs b/esm/identity.mjs
nnew file mode 100644
iindex 0000000..cb61486
---- /dev/null
++++ b/esm/identity.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/identity.js. Do not edit.
++import cjs from '../src/identity.js';
++
++export const {
++ createIdentityClient,
++} = cjs;
++export default cjs;
ddiff --git a/esm/index.mjs b/esm/index.mjs
nnew file mode 100644
iindex 0000000..0c96404
---- /dev/null
++++ b/esm/index.mjs
@@@ -0,0 +1,11 @@
++// Generated by scripts/esm.js from index.js. Do not edit.
++import cjs from '../index.js';
++
++export const {
++ createClient, OpenVibeError, isOpenVibeError, paginate, offsetPager, parseTraceparent,
++ startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
++ satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
++ registry, identity, modules, events, realtime, media,
++ community,
++} = cjs;
++export default cjs;
ddiff --git a/esm/media.mjs b/esm/media.mjs
nnew file mode 100644
iindex 0000000..e3f889f
---- /dev/null
++++ b/esm/media.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/media.js. Do not edit.
++import cjs from '../src/media.js';
++
++export const {
++ createMediaClient, mediaUrls, DEFAULT_PUBLIC_ORIGIN,
++} = cjs;
++export default cjs;
ddiff --git a/esm/modules.mjs b/esm/modules.mjs
nnew file mode 100644
iindex 0000000..90c4c63
---- /dev/null
++++ b/esm/modules.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/modules.js. Do not edit.
++import cjs from '../src/modules.js';
++
++export const {
++ createModulesClient,
++} = cjs;
++export default cjs;
ddiff --git a/esm/realtime.mjs b/esm/realtime.mjs
nnew file mode 100644
iindex 0000000..67af491
---- /dev/null
++++ b/esm/realtime.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/realtime.js. Do not edit.
++import cjs from '../src/realtime.js';
++
++export const {
++ subscribe, createRealtimeClient, DEFAULT_ORIGIN,
++} = cjs;
++export default cjs;
ddiff --git a/esm/registry.mjs b/esm/registry.mjs
nnew file mode 100644
iindex 0000000..ae541cd
---- /dev/null
++++ b/esm/registry.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/registry.js. Do not edit.
++import cjs from '../src/registry.js';
++
++export const {
++ createRegistryClient,
++} = cjs;
++export default cjs;
ddiff --git a/esm/testing.mjs b/esm/testing.mjs
nnew file mode 100644
iindex 0000000..9bbf119
---- /dev/null
++++ b/esm/testing.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/testing/index.js. Do not edit.
++import cjs from '../src/testing/index.js';
++
++export const {
++ createMockPlatform, DEFAULT_ORIGINS,
++} = cjs;
++export default cjs;
ddiff --git a/index.js b/index.js
nnew file mode 100644
iindex 0000000..f07556f
---- /dev/null
++++ b/index.js
@@@ -0,0 +1,19 @@
++'use strict';
++/**
++ * openvibe-sdk (Node entry). Everything from ./core at the top level, and each capability family
++ * as a namespace. Browser bundles resolve ./browser.js instead (no server-only modules).
++ * Prefer the subpaths (openvibe-sdk/media, …) so a bundle only carries what it uses.
++ */
++const core = require('./src/core');
++
++module.exports = {
++ ...core,
++ auth: require('./src/auth'),
++ registry: require('./src/registry'),
++ identity: require('./src/identity'),
++ modules: require('./src/modules'),
++ events: require('./src/events'),
++ realtime: require('./src/realtime'),
++ media: require('./src/media'),
++ community: require('./src/community'),
++};
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..c148cce
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,105 @@
++{
++ "name": "openvibe-sdk",
++ "version": "0.1.0",
++ "description": "Supported browser and server clients for the OpenVibe platform: auth, registry discovery, identity, user modules, events, realtime, media and community, with deadlines, safe retries, problem errors and trace propagation.",
++ "main": "index.js",
++ "types": "types/index.d.ts",
++ "browser": "browser.js",
++ "exports": {
++ ".": {
++ "types": "./types/index.d.ts",
++ "browser": "./browser.js",
++ "import": "./esm/index.mjs",
++ "default": "./index.js"
++ },
++ "./core": {
++ "types": "./types/core.d.ts",
++ "import": "./esm/core.mjs",
++ "default": "./src/core/index.js"
++ },
++ "./auth": {
++ "types": "./types/auth.d.ts",
++ "browser": "./src/auth/browser.js",
++ "import": "./esm/auth.mjs",
++ "default": "./src/auth/index.js"
++ },
++ "./auth/browser": {
++ "types": "./types/auth-browser.d.ts",
++ "import": "./esm/auth-browser.mjs",
++ "default": "./src/auth/browser.js"
++ },
++ "./registry": {
++ "types": "./types/registry.d.ts",
++ "import": "./esm/registry.mjs",
++ "default": "./src/registry.js"
++ },
++ "./identity": {
++ "types": "./types/identity.d.ts",
++ "browser": null,
++ "import": "./esm/identity.mjs",
++ "default": "./src/identity.js"
++ },
++ "./modules": {
++ "types": "./types/modules.d.ts",
++ "import": "./esm/modules.mjs",
++ "default": "./src/modules.js"
++ },
++ "./events": {
++ "types": "./types/events.d.ts",
++ "browser": null,
++ "import": "./esm/events.mjs",
++ "default": "./src/events.js"
++ },
++ "./realtime": {
++ "types": "./types/realtime.d.ts",
++ "import": "./esm/realtime.mjs",
++ "default": "./src/realtime.js"
++ },
++ "./media": {
++ "types": "./types/media.d.ts",
++ "import": "./esm/media.mjs",
++ "default": "./src/media.js"
++ },
++ "./community": {
++ "types": "./types/community.d.ts",
++ "import": "./esm/community.mjs",
++ "default": "./src/community.js"
++ },
++ "./testing": {
++ "types": "./types/testing.d.ts",
++ "browser": null,
++ "import": "./esm/testing.mjs",
++ "default": "./src/testing/index.js"
++ },
++ "./package.json": "./package.json"
++ },
++ "files": [
++ "index.js",
++ "browser.js",
++ "src/",
++ "esm/",
++ "types/",
++ "README.md",
++ "CHANGELOG.md",
++ "LICENSE"
++ ],
++ "scripts": {
++ "test": "node test/run.js",
++ "esm": "node scripts/esm.js"
++ },
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.6.0"
++ },
++ "peerDependenciesMeta": {
++ "openvibe-contracts": {
++ "optional": true
++ }
++ },
++ "sideEffects": false,
++ "keywords": ["openvibe", "sdk", "oauth", "pkce", "events", "sse", "media"],
++ "repository": "github:OpenVibers/OpenVibe.SDK",
++ "license": "MIT"
++}
ddiff --git a/scripts/esm.js b/scripts/esm.js
nnew file mode 100644
iindex 0000000..f21a159
---- /dev/null
++++ b/scripts/esm.js
@@@ -0,0 +1,54 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * The ESM entry points (esm/*.mjs) re-export the CommonJS modules with named exports. They are
++ * checked in (no build step); this script writes them, and test/esm.test.js fails if they are stale.
++ *
++ * node scripts/esm.js write esm/*.mjs
++ * node scripts/esm.js --check exit 1 if any file differs
++ */
++const fs = require('fs');
++const path = require('path');
++
++const ROOT = path.join(__dirname, '..');
++const ENTRIES = {
++ 'index.mjs': '../index.js',
++ 'core.mjs': '../src/core/index.js',
++ 'auth.mjs': '../src/auth/index.js',
++ 'auth-browser.mjs': '../src/auth/browser.js',
++ 'registry.mjs': '../src/registry.js',
++ 'identity.mjs': '../src/identity.js',
++ 'modules.mjs': '../src/modules.js',
++ 'events.mjs': '../src/events.js',
++ 'realtime.mjs': '../src/realtime.js',
++ 'media.mjs': '../src/media.js',
++ 'community.mjs': '../src/community.js',
++ 'testing.mjs': '../src/testing/index.js',
++};
++
++function render(file, target) {
++ const names = Object.keys(require(path.join(ROOT, 'esm', target)));
++ const lines = [];
++ for (let i = 0; i < names.length; i += 6) lines.push(` ${names.slice(i, i + 6).join(', ')},`);
++ return `// Generated by scripts/esm.js from ${target.slice(3)}. Do not edit.\n`
++ + `import cjs from '${target}';\n\n`
++ + `export const {\n${lines.join('\n')}\n} = cjs;\nexport default cjs;\n`;
++}
++
++function main() {
++ const check = process.argv.includes('--check');
++ let stale = 0;
++ for (const [file, target] of Object.entries(ENTRIES)) {
++ const out = render(file, target);
++ const p = path.join(ROOT, 'esm', file);
++ const have = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
++ if (have === out) continue;
++ stale++;
++ if (check) console.error(`stale: esm/${file}`);
++ else fs.writeFileSync(p, out);
++ }
++ if (check && stale) process.exit(1);
++}
++
++if (require.main === module) main();
++module.exports = { ENTRIES, render };
ddiff --git a/src/auth/browser.js b/src/auth/browser.js
nnew file mode 100644
iindex 0000000..7b67b97
---- /dev/null
++++ b/src/auth/browser.js
@@@ -0,0 +1,95 @@
++'use strict';
++/**
++ * openvibe-sdk/auth, browser entry: OAuth2 authorization code + PKCE (RFC 7636, S256).
++ *
++ * The browser builds the authorize URL and keeps the verifier (sessionStorage, or your server's
++ * session). The code is then exchanged ON YOUR SERVER (openvibe-sdk/auth server entry,
++ * exchangeCode()), because every OpenVibe OAuth client is a confidential client: its credentials
++ * never reach a browser. This file holds no credentials and uses Web Crypto only.
++ */
++const { OpenVibeError } = require('../core/errors');
++const { randomBytes } = require('../core/ids');
++const { DEFAULT_NETWORK } = require('../core/client');
++
++function base64url(bytes) {
++ let bin = '';
++ for (const b of bytes) bin += String.fromCharCode(b);
++ return btoa(bin).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
++}
++
++/** A random code verifier: 43..128 characters of [A-Za-z0-9-._~] (default 64). */
++function createCodeVerifier(length = 64) {
++ if (!Number.isInteger(length) || length < 43 || length > 128) throw new RangeError('PKCE verifier length must be 43..128');
++ return base64url(randomBytes(Math.ceil(length * 3 / 4))).slice(0, length);
++}
++
++/** S256 challenge: BASE64URL(SHA-256(ASCII(verifier))). */
++async function pkceChallenge(verifier) {
++ const subtle = globalThis.crypto && globalThis.crypto.subtle;
++ if (!subtle) throw new Error('openvibe-sdk: Web Crypto subtle digest is required for PKCE');
++ const digest = await subtle.digest('SHA-256', new TextEncoder().encode(String(verifier)));
++ return base64url(new Uint8Array(digest));
++}
++
++/** { codeVerifier, codeChallenge, codeChallengeMethod: 'S256' } */
++async function createPkcePair(length) {
++ const codeVerifier = createCodeVerifier(length);
++ return { codeVerifier, codeChallenge: await pkceChallenge(codeVerifier), codeChallengeMethod: 'S256' };
++}
++
++/** An unguessable `state` value that ties the callback to this browser. */
++function createState() {
++ return base64url(randomBytes(24));
++}
++
++/**
++ * GET <network>/oauth/authorize?response_type=code&client_id&redirect_uri&scope&state
++ * &code_challenge&code_challenge_method=S256[&prompt=none]
++ */
++function buildAuthorizeUrl({ network = DEFAULT_NETWORK, authorizeUrl, clientId, redirectUri, scope = 'profile theme', state, codeChallenge, codeChallengeMethod = 'S256', prompt } = {}) {
++ if (!clientId || !redirectUri) throw new TypeError('clientId and redirectUri are required');
++ const u = new URL(authorizeUrl || `${String(network).replace(/\/+$/, '')}/oauth/authorize`);
++ u.searchParams.set('response_type', 'code');
++ u.searchParams.set('client_id', clientId);
++ u.searchParams.set('redirect_uri', redirectUri);
++ if (scope) u.searchParams.set('scope', Array.isArray(scope) ? scope.join(' ') : scope);
++ if (state) u.searchParams.set('state', state);
++ if (codeChallenge) {
++ u.searchParams.set('code_challenge', codeChallenge);
++ u.searchParams.set('code_challenge_method', codeChallengeMethod);
++ }
++ if (prompt) u.searchParams.set('prompt', prompt);
++ return u.toString();
++}
++
++/**
++ * Everything a sign-in button needs: { url, state, codeVerifier, codeChallenge }.
++ * Store `state` and `codeVerifier` until the callback, then send the code and verifier to your server.
++ */
++async function startAuthorization(opts = {}) {
++ const pair = await createPkcePair(opts.verifierLength);
++ const state = opts.state || createState();
++ const url = buildAuthorizeUrl({ ...opts, state, codeChallenge: pair.codeChallenge, codeChallengeMethod: 'S256' });
++ return { url, state, codeVerifier: pair.codeVerifier, codeChallenge: pair.codeChallenge };
++}
++
++/**
++ * Read the redirect back from the Network: { code, state } or throws OpenVibeError
++ * (oauth.<error> such as oauth.login_required after prompt=none, or oauth.state_mismatch).
++ */
++function readCallback(location, { expectedState } = {}) {
++ const u = new URL(String(location), 'http://localhost');
++ const p = u.searchParams;
++ const state = p.get('state') || '';
++ if (p.get('error')) {
++ throw new OpenVibeError({ code: `oauth.${p.get('error')}`, status: 400, detail: p.get('error_description') || p.get('error'), message: `authorization failed: ${p.get('error')}` });
++ }
++ if (expectedState !== undefined && state !== expectedState) {
++ throw new OpenVibeError({ code: 'oauth.state_mismatch', status: 400, message: 'the callback state does not match the one this browser started with' });
++ }
++ const code = p.get('code');
++ if (!code) throw new OpenVibeError({ code: 'oauth.missing_code', status: 400, message: 'the callback carries no authorization code' });
++ return { code, state };
++}
++
++module.exports = { createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization, readCallback, base64url };
ddiff --git a/src/auth/index.js b/src/auth/index.js
nnew file mode 100644
iindex 0000000..2a036eb
---- /dev/null
++++ b/src/auth/index.js
@@@ -0,0 +1,11 @@
++'use strict';
++/**
++ * openvibe-sdk/auth, server entry (Node). Bundlers resolving the `browser` condition get
++ * ./browser.js instead, which has the PKCE helpers only.
++ */
++const browser = require('./browser');
++const { createServiceTokenClient } = require('./tokens');
++const { verifyUserToken } = require('./jwt');
++const { exchangeCode, refreshUserToken } = require('./oauth');
++
++module.exports = { ...browser, createServiceTokenClient, verifyUserToken, exchangeCode, refreshUserToken };
ddiff --git a/src/auth/jwt.js b/src/auth/jwt.js
nnew file mode 100644
iindex 0000000..6ea36c7
---- /dev/null
++++ b/src/auth/jwt.js
@@@ -0,0 +1,100 @@
++'use strict';
++/**
++ * Server only. Offline RS256 verification of an OpenVibe.Network user token (the SSO access token:
++ * sub, id, subject_id, username, role, …) against the Network JWKS. No JWT library: the rules
++ * are the ones written here.
++ *
++ * const claims = await verifyUserToken(token, { jwks: 'https://openvibe.network/api/.well-known/jwks' });
++ * claims.subject_id // usr_… (canonical subject; absent on very old tokens)
++ *
++ * `jwks` is the JWKS document ({ keys: [...] }, Network's shape also carries public_key PEM), a URL
++ * to fetch it from (cached), or pass `publicKey` (PEM or KeyObject). Throws OpenVibeError (401)
++ * with a stable code: token.malformed | token.bad_signature | token.expired | token.not_yet_valid |
++ * token.wrong_issuer | token.wrong_audience | token.not_user | token.no_key.
++ */
++const crypto = require('node:crypto');
++const { OpenVibeError } = require('../core/errors');
++
++const fromB64url = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');
++const JWKS_TTL_MS = 6 * 60 * 60 * 1000;
++const jwksCache = new Map(); // url -> { at, keys: KeyObject-with-kid[] , inflight }
++
++function fail(code, detail) {
++ return new OpenVibeError({ code, status: 401, detail, message: `user token rejected: ${detail}` });
++}
++
++function keysFromJwks(doc) {
++ const out = [];
++ if (!doc || typeof doc !== 'object') return out;
++ for (const jwk of Array.isArray(doc.keys) ? doc.keys : []) {
++ if (!jwk || jwk.kty !== 'RSA' || (jwk.alg && jwk.alg !== 'RS256') || (jwk.use && jwk.use !== 'sig')) continue;
++ try { out.push({ kid: jwk.kid || null, key: crypto.createPublicKey({ key: jwk, format: 'jwk' }) }); } catch { /* skip unusable key */ }
++ }
++ if (typeof doc.public_key === 'string' && doc.public_key.includes('BEGIN')) {
++ try { out.push({ kid: null, key: crypto.createPublicKey(doc.public_key) }); } catch { /* skip */ }
++ }
++ return out;
++}
++
++async function loadJwks(url, fetchImpl, force) {
++ const hit = jwksCache.get(url);
++ if (hit && hit.keys && !force && Date.now() - hit.at < JWKS_TTL_MS) return hit.keys;
++ if (hit && hit.inflight) return hit.inflight;
++ const entry = hit || {};
++ entry.inflight = (async () => {
++ const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(5000) });
++ if (!res.ok) throw new OpenVibeError({ code: 'token.no_key', status: 503, message: `JWKS ${url} answered ${res.status}` });
++ const keys = keysFromJwks(await res.json());
++ Object.assign(entry, { at: Date.now(), keys });
++ return keys;
++ })().finally(() => { entry.inflight = null; });
++ jwksCache.set(url, entry);
++ return entry.inflight;
++}
++
++function verifyWith(keys, header, input, sig) {
++ const byKid = header.kid ? keys.filter((k) => k.kid === header.kid) : [];
++ const candidates = byKid.length ? byKid : keys;
++ return candidates.some(({ key }) => {
++ try { return crypto.verify('RSA-SHA256', Buffer.from(input), key, sig); } catch { return false; }
++ });
++}
++
++async function verifyUserToken(token, { jwks, publicKey, issuer, audience, clockSkewSec = 30, now = Date.now(), fetch: fetchImpl = globalThis.fetch, allowServiceTokens = false } = {}) {
++ const parts = typeof token === 'string' ? token.split('.') : [];
++ if (parts.length !== 3 || !parts[2]) throw fail('token.malformed', 'not a signed JWT');
++ let header, claims;
++ try { header = JSON.parse(fromB64url(parts[0])); claims = JSON.parse(fromB64url(parts[1])); } catch { throw fail('token.malformed', 'undecodable'); }
++ if (!header || header.alg !== 'RS256') throw fail('token.malformed', `alg ${header && header.alg} not accepted`);
++ if (!claims || typeof claims !== 'object') throw fail('token.malformed', 'claims are not an object');
++
++ let keys;
++ if (publicKey) keys = [{ kid: null, key: typeof publicKey === 'string' ? crypto.createPublicKey(publicKey) : publicKey }];
++ else if (typeof jwks === 'string') keys = await loadJwks(jwks, fetchImpl, false);
++ else keys = keysFromJwks(jwks);
++ if (!keys.length) throw fail('token.no_key', 'no RS256 verification key');
++
++ const input = `${parts[0]}.${parts[1]}`;
++ const sig = fromB64url(parts[2]);
++ let good = verifyWith(keys, header, input, sig);
++ if (!good && typeof jwks === 'string' && header.kid && !keys.some((k) => k.kid === header.kid)) {
++ good = verifyWith(await loadJwks(jwks, fetchImpl, true), header, input, sig); // key rotation
++ }
++ if (!good) throw fail('token.bad_signature', 'signature does not verify');
++
++ const t = Math.floor(now / 1000);
++ if (typeof claims.exp !== 'number' || claims.exp + clockSkewSec < t) throw fail('token.expired', 'expired');
++ if (typeof claims.nbf === 'number' && claims.nbf - clockSkewSec > t) throw fail('token.not_yet_valid', 'not valid yet');
++ if (typeof claims.iat === 'number' && claims.iat - clockSkewSec > t) throw fail('token.not_yet_valid', 'issued in the future');
++ if (issuer && claims.iss !== issuer) throw fail('token.wrong_issuer', `issuer ${claims.iss}`);
++ if (audience) {
++ const aud = Array.isArray(claims.aud) ? claims.aud : claims.aud != null ? [claims.aud] : [];
++ const want = Array.isArray(audience) ? audience : [audience];
++ if (!want.some((a) => aud.includes(a))) throw fail('token.wrong_audience', `not for ${want.join(', ')}`);
++ }
++ const principal = ['service', 'app', 'mod'].includes(claims.actor_type) || /^(svc|app|mod):/.test(String(claims.sub || ''));
++ if (principal && !allowServiceTokens) throw fail('token.not_user', 'a service principal token is not a user token');
++ return claims;
++}
++
++module.exports = { verifyUserToken, keysFromJwks, _jwksCache: jwksCache };
ddiff --git a/src/auth/oauth.js b/src/auth/oauth.js
nnew file mode 100644
iindex 0000000..4677cb9
---- /dev/null
++++ b/src/auth/oauth.js
@@@ -0,0 +1,46 @@
++'use strict';
++/**
++ * Server only. The half of the authorization-code flow that needs the client's credentials:
++ * exchanging the code the browser brought back (with its PKCE verifier) and refreshing.
++ *
++ * POST <network>/oauth/token grant_type=authorization_code, code, redirect_uri, client_id,
++ * client_secret, code_verifier
++ * -> { access_token, refresh_token, token_type, expires_in, scope, user, preferences }
++ */
++const { OpenVibeError } = require('../core/errors');
++const { DEFAULT_NETWORK } = require('../core/client');
++
++async function postToken(url, params, fetchImpl, timeoutMs) {
++ let res;
++ try {
++ res = await fetchImpl(url, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
++ body: new URLSearchParams(Object.entries(params).filter(([, v]) => v != null)).toString(),
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ } catch (err) {
++ throw new OpenVibeError({ code: err && err.name === 'TimeoutError' ? 'sdk.timeout' : 'sdk.network_error', message: `token endpoint unreachable: ${err && err.message}`, cause: err, method: 'POST', url });
++ }
++ const json = await res.json().catch(() => null);
++ if (!res.ok || !json || !json.access_token) throw OpenVibeError.fromResponse({ status: res.status, body: json, method: 'POST', url });
++ return json;
++}
++
++function tokenUrlOf({ tokenUrl, network = DEFAULT_NETWORK }) {
++ return tokenUrl || `${String(network).replace(/\/+$/, '')}/oauth/token`;
++}
++
++async function exchangeCode({ code, redirectUri, codeVerifier, clientId, clientSecret, fetch: fetchImpl = globalThis.fetch, timeoutMs = 10000, ...rest } = {}) {
++ if (!code || !redirectUri || !clientId || !clientSecret) throw new TypeError('exchangeCode: code, redirectUri, clientId and clientSecret are required');
++ return postToken(tokenUrlOf(rest), {
++ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: clientId, client_secret: clientSecret, code_verifier: codeVerifier,
++ }, fetchImpl, timeoutMs);
++}
++
++async function refreshUserToken({ refreshToken, clientId, clientSecret, fetch: fetchImpl = globalThis.fetch, timeoutMs = 10000, ...rest } = {}) {
++ if (!refreshToken || !clientId || !clientSecret) throw new TypeError('refreshUserToken: refreshToken, clientId and clientSecret are required');
++ return postToken(tokenUrlOf(rest), { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId, client_secret: clientSecret }, fetchImpl, timeoutMs);
++}
++
++module.exports = { exchangeCode, refreshUserToken };
ddiff --git a/src/auth/tokens.js b/src/auth/tokens.js
nnew file mode 100644
iindex 0000000..4292d32
---- /dev/null
++++ b/src/auth/tokens.js
@@@ -0,0 +1,79 @@
++'use strict';
++/**
++ * Server only. Service/app principal tokens from OpenVibe.Network (ADR-003, contract
++ * identity.service-token-claims@1):
++ *
++ * POST <network>/oauth/token grant_type=client_credentials, client_id, client_secret, audience[, scope]
++ * -> { access_token, token_type: 'Bearer', expires_in: 300, scope }
++ *
++ * Same semantics as openvibe-contracts serviceAuth.createTokenClient(), standalone: one cached token
++ * per audience (and scope), refreshed 60 s before expiry; concurrent callers share one request.
++ * Plug it into createClient({ tokenProvider }) and every call gets a token for the audience of the
++ * service it calls (openvibe.media, openvibe.events, …).
++ */
++const { OpenVibeError } = require('../core/errors');
++const { DEFAULT_NETWORK } = require('../core/client');
++
++function createServiceTokenClient({
++ network = DEFAULT_NETWORK, tokenUrl, clientId, clientSecret, audience, scope,
++ fetch: fetchImpl = globalThis.fetch, timeoutMs = 5000, refreshSkewMs = 60000, now = () => Date.now(),
++} = {}) {
++ if (!clientId || !clientSecret) throw new TypeError('createServiceTokenClient: clientId and clientSecret are required');
++ const url = tokenUrl || `${String(network).replace(/\/+$/, '')}/oauth/token`;
++ const cache = new Map(); // key -> { token, exp }
++ const inflight = new Map(); // key -> Promise<string>
++
++ const scopeFor = (aud, override) => {
++ const s = override !== undefined ? override : (scope && typeof scope === 'object' && !Array.isArray(scope) ? scope[aud] : scope);
++ return Array.isArray(s) ? s.join(' ') : s || undefined;
++ };
++ const keyOf = (aud, s) => `${aud}\n${s || ''}`;
++
++ async function fetchToken(aud, s, key) {
++ const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret, audience: aud });
++ if (s) body.set('scope', s);
++ let res;
++ try {
++ res = await fetchImpl(url, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
++ body: body.toString(),
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ } catch (err) {
++ throw new OpenVibeError({ code: err && err.name === 'TimeoutError' ? 'sdk.timeout' : 'sdk.network_error', retryable: true, message: `token endpoint unreachable: ${err && err.message}`, cause: err, method: 'POST', url });
++ }
++ const json = await res.json().catch(() => null);
++ if (!res.ok || !json || !json.access_token) {
++ throw OpenVibeError.fromResponse({ status: res.status, body: json || { error: 'no_token', error_description: 'no access_token in the response' }, method: 'POST', url });
++ }
++ cache.set(key, { token: json.access_token, exp: now() + (Number(json.expires_in) || 300) * 1000, scope: json.scope });
++ return json.access_token;
++ }
++
++ /** getToken({ audience?, scope? }) -> access token for that audience. */
++ async function getToken(ctx = {}) {
++ const aud = ctx.audience || audience;
++ if (!aud) throw new TypeError('getToken: no audience (pass one to createServiceTokenClient or per call)');
++ const s = scopeFor(aud, ctx.scope);
++ const key = keyOf(aud, s);
++ const hit = cache.get(key);
++ if (hit && hit.exp - refreshSkewMs > now()) return hit.token;
++ if (!inflight.has(key)) inflight.set(key, fetchToken(aud, s, key).finally(() => inflight.delete(key)));
++ return inflight.get(key);
++ }
++
++ return {
++ getToken,
++ async authHeaders(ctx) { return { Authorization: `Bearer ${await getToken(ctx)}` }; },
++ /** Drop the cached token (one audience, or all): the next call fetches a new one. */
++ invalidate(ctx = {}) {
++ const aud = ctx.audience || audience;
++ if (!aud) { cache.clear(); return; }
++ for (const k of [...cache.keys()]) if (k.startsWith(`${aud}\n`)) cache.delete(k);
++ },
++ tokenUrl: url,
++ };
++}
++
++module.exports = { createServiceTokenClient };
ddiff --git a/src/community.js b/src/community.js
nnew file mode 100644
iindex 0000000..3f63401
---- /dev/null
++++ b/src/community.js
@@@ -0,0 +1,116 @@
++'use strict';
++/**
++ * openvibe-sdk/community: OpenVibe.Community pastes (/api/pastes). Browser-safe.
++ *
++ * Browsers call as the signed-in person (Network ov_token cookie with credentials: 'include', or
++ * a Bearer user JWT) or anonymously. First-party services call with a Network service token
++ * (audience openvibe.community, community.paste.create / .write / .moderate) and say who they
++ * act for:
++ * actingSubject: 'usr_…' | 'gst_…' -> X-OV-Subject (without it a write is anonymous)
++ * origin: 'ai' -> X-OV-Origin: ai (AI output; never attributed to a person,
++ * so no subject is sent with it)
++ * sourceRef: { service, type, id } -> X-OV-Source-Ref (EntityRef, e.g. the stream it came from)
++ * staff: true -> X-OV-Staff: 1 (the acting person is staff; needs .moderate)
++ * Community ignores X-OV-* headers from browsers.
++ *
++ * TODO: comments on other content, the forum and Pulse APIs are being added to Community now and
++ * are not wrapped yet; paste comments are.
++ */
++const { isOpenVibeError } = require('./core/errors');
++const { isActingSubjectId } = require('./core/ids');
++const { paginate, offsetPager } = require('./core/paginate');
++
++const enc = encodeURIComponent;
++
++function actingHeaders({ actingSubject, origin, sourceRef, staff } = {}) {
++ const h = {};
++ if (origin !== undefined && origin !== null) {
++ if (origin !== 'ai' && origin !== 'user') throw new TypeError('origin must be "ai" or "user"');
++ h['X-OV-Origin'] = origin;
++ }
++ if (actingSubject && origin !== 'ai') {
++ if (!isActingSubjectId(actingSubject)) throw new TypeError('actingSubject must be a usr_… or gst_… subject id');
++ h['X-OV-Subject'] = actingSubject;
++ }
++ if (sourceRef) h['X-OV-Source-Ref'] = typeof sourceRef === 'string' ? sourceRef : JSON.stringify(sourceRef);
++ if (staff) h['X-OV-Staff'] = '1';
++ return h;
++}
++
++function createCommunityClient(client, defaults = {}) {
++ const { baseUrl } = defaults;
++ const pick = (o) => ({ actingSubject: o.actingSubject, origin: o.origin, sourceRef: o.sourceRef, staff: o.staff });
++ const base = pick(defaults);
++
++ function call(opts, perCall = {}) {
++ const acting = { ...base };
++ for (const [k, v] of Object.entries(pick(perCall))) if (v !== undefined) acting[k] = v;
++ return client.json({
++ service: 'community', baseUrl, audience: 'openvibe.community', ...opts,
++ headers: { ...actingHeaders(acting), ...opts.headers }, signal: perCall.signal,
++ });
++ }
++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
++ const once = { idempotencyKey: false }; // Community does not dedupe these writes: never repeat them
++
++ const pastes = {
++ /** { pastes, total, limit, offset } — query: limit, offset, type, search, sort, origin, username */
++ list: (query = {}, o = {}) => call({ path: '/api/pastes', query }, o),
++ iterate(query = {}, o = {}) {
++ const limit = query.limit || 50;
++ return paginate(offsetPager(async (offset) => {
++ const page = await pastes.list({ ...query, limit, offset }, o);
++ return { items: page.pastes || [], total: page.total };
++ }, { limit }), { cursor: query.offset || 0 });
++ },
++ /** { paste, liked } or null. noView: true does not count a view. */
++ get: (slug, { noView, ...o } = {}) => orNull(call({ path: `/api/pastes/${enc(slug)}`, query: { no_view: noView ? '1' : undefined } }, o)),
++ /**
++ * Text paste: { title?, content, language?, visibility?, burn_after_read?, is_nsfw?, … }.
++ * Screenshot paste: { screenshot: Blob|File|Buffer, filename?, title?, visibility? } (multipart).
++ * -> { id, slug, url, … }
++ */
++ create(input = {}, o = {}) {
++ if (input.screenshot != null) {
++ const { screenshot, filename, contentType, ...fields } = input;
++ const form = new FormData();
++ for (const [k, v] of Object.entries(fields)) if (v !== undefined && v !== null) form.append(k, typeof v === 'object' ? JSON.stringify(v) : String(v));
++ const blob = typeof Blob !== 'undefined' && screenshot instanceof Blob ? screenshot : new Blob([screenshot], { type: contentType || 'image/png' });
++ form.append('screenshot', blob, filename || (screenshot && screenshot.name) || 'screenshot.png');
++ return call({ method: 'POST', path: '/api/pastes', form, ...once }, o);
++ }
++ return call({ method: 'POST', path: '/api/pastes', json: input, ...once }, o);
++ },
++ /** { paste } — owner or staff. */
++ update: (slug, patch, o = {}) => call({ method: 'PUT', path: `/api/pastes/${enc(slug)}`, json: patch }, o),
++ delete: (slug, o = {}) => call({ method: 'DELETE', path: `/api/pastes/${enc(slug)}` }, o),
++ /** A copy of a text paste owned by the caller -> { id, slug, url, … } */
++ fork: (slug, o = {}) => call({ method: 'POST', path: `/api/pastes/${enc(slug)}/fork`, json: {}, ...once }, o),
++ /** Toggles the caller's like -> { liked, likes }. */
++ like: (slug, o = {}) => call({ method: 'POST', path: `/api/pastes/${enc(slug)}/like`, ...once }, o),
++ /** Counts a copy -> { copies }. */
++ copy: (slug, o = {}) => call({ method: 'POST', path: `/api/pastes/${enc(slug)}/copy`, ...once }, o),
++ versions: (slug, o = {}) => call({ path: `/api/pastes/${enc(slug)}/versions` }, o),
++ byUser: (username, query = {}, o = {}) => call({ path: `/api/pastes/by-user/${enc(username)}`, query }, o),
++ config: (o = {}) => call({ path: '/api/pastes/config' }, o),
++ comments: {
++ /** { comments, total } */
++ list: (slug, query = {}, o = {}) => call({ path: `/api/pastes/${enc(slug)}/comments`, query }, o),
++ /** { content, parent_id? } -> { comment } */
++ create: (slug, input, o = {}) => call({ method: 'POST', path: `/api/pastes/${enc(slug)}/comments`, json: input, ...once }, o),
++ delete: (slug, commentId, o = {}) => call({ method: 'DELETE', path: `/api/pastes/${enc(slug)}/comments/${enc(commentId)}` }, o),
++ },
++ };
++
++ return {
++ pastes,
++ /** The same client acting for someone else: as('usr_…') or as({ actingSubject, origin, sourceRef, staff }). */
++ as(who) {
++ const next = typeof who === 'string' ? { actingSubject: who } : { ...who };
++ return createCommunityClient(client, { ...defaults, ...next });
++ },
++ headers: () => actingHeaders(base),
++ };
++}
++
++module.exports = { createCommunityClient, actingHeaders };
ddiff --git a/src/core/client.js b/src/core/client.js
nnew file mode 100644
iindex 0000000..1145e85
---- /dev/null
++++ b/src/core/client.js
@@@ -0,0 +1,336 @@
++'use strict';
++/**
++ * createClient(): the one HTTP client every SDK subpath uses. Browser-safe (global fetch, Web
++ * Crypto, no Node modules).
++ *
++ * - service origins come from explicit `baseUrls` or from the registry (`discover()`); callers
++ * name a service ('media', 'events', …) and a path, never a hard-coded host
++ * - every call has a deadline (`timeoutMs` per attempt, `deadlineMs` for the whole call)
++ * - retries only when repeating is safe: idempotent methods, or a request carrying an
++ * Idempotency-Key (one is generated for mutations when retries > 0 unless the caller opts out
++ * with `idempotencyKey: false`)
++ * - W3C traceparent + X-OpenVibe-Request-Id on every call
++ * - failures are OpenVibeError (RFC 9457 problem details mapped to code/status/detail/requestId/traceId)
++ */
++const { OpenVibeError } = require('./errors');
++const { startSpan, contextFromHeaders } = require('./trace');
++const { newIdempotencyKey, randomHex } = require('./ids');
++const { satisfies } = require('./semver');
++
++const DEFAULT_NETWORK = 'https://openvibe.network';
++/** Contract releases this SDK version was built and tested against. */
++const CONTRACTS_RANGE = '>=0.5.0 <1.0.0';
++const IDEMPOTENT = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
++const RETRY_STATUS = new Set([408, 425, 429, 502, 503, 504]);
++
++const trimSlash = (s) => String(s).replace(/\/+$/, '');
++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
++
++function createClient(options = {}) {
++ const shared = { discovery: null, discoveredAt: 0, inflight: null, warned: false };
++ return build(normalize(options), shared, {});
++}
++
++function normalize(o) {
++ const fetchImpl = o.fetch || (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null);
++ if (!fetchImpl) throw new TypeError('openvibe-sdk: no fetch available; pass options.fetch');
++ const sources = [o.token != null, typeof o.getToken === 'function', Boolean(o.tokenProvider)].filter(Boolean).length;
++ if (sources > 1) throw new TypeError('openvibe-sdk: pass only one of token, getToken, tokenProvider');
++ const network = trimSlash(o.network || DEFAULT_NETWORK);
++ let getToken = null;
++ if (typeof o.getToken === 'function') getToken = o.getToken;
++ else if (o.tokenProvider) getToken = (ctx) => o.tokenProvider.getToken(ctx);
++ let invalidateToken = null;
++ if (o.tokenProvider && typeof o.tokenProvider.invalidate === 'function') invalidateToken = (ctx) => o.tokenProvider.invalidate(ctx);
++ else if (typeof o.invalidateToken === 'function') invalidateToken = o.invalidateToken;
++ return Object.freeze({
++ network,
++ discoveryUrl: o.discoveryUrl || `${network}/.well-known/openvibe`,
++ baseUrls: Object.freeze(Object.fromEntries(Object.entries(o.baseUrls || {}).map(([k, v]) => [k, trimSlash(v)]))),
++ audiences: Object.freeze({ ...o.audiences }),
++ fetch: fetchImpl,
++ timeoutMs: o.timeoutMs ?? 10000,
++ deadlineMs: o.deadlineMs ?? 30000,
++ retries: o.retries ?? 2,
++ retryDelayMs: o.retryDelayMs ?? 250,
++ maxRetryDelayMs: o.maxRetryDelayMs ?? 5000,
++ token: o.token ?? null,
++ getToken,
++ invalidateToken,
++ headers: Object.freeze({ ...o.headers }),
++ credentials: o.credentials,
++ discoveryTtlMs: o.discoveryTtlMs ?? 5 * 60 * 1000,
++ autoDiscover: o.autoDiscover !== false,
++ contractsRange: o.contractsRange || CONTRACTS_RANGE,
++ strictContracts: Boolean(o.strictContracts),
++ traceparent: o.traceparent || null,
++ onWarning: typeof o.onWarning === 'function' ? o.onWarning : (msg) => { if (typeof console !== 'undefined') console.warn(`[openvibe-sdk] ${msg}`); },
++ });
++}
++
++function build(cfg, shared, ctx) {
++ const audienceOf = (service) => cfg.audiences[service] || `openvibe.${service}`;
++
++ function cachedDiscovery() {
++ return shared.discovery && Date.now() - shared.discoveredAt < cfg.discoveryTtlMs ? shared.discovery : null;
++ }
++
++ /**
++ * Read the platform descriptor (GET <network>/.well-known/openvibe) and cache it: service
++ * origins, token/JWKS endpoints, and the contracts release the network runs. Concurrent
++ * callers share one request.
++ */
++ async function discover({ force = false } = {}) {
++ const fresh = cachedDiscovery();
++ if (fresh && !force) return fresh;
++ if (!shared.inflight) {
++ shared.inflight = (async () => {
++ const { data } = await request({ url: cfg.discoveryUrl, auth: false });
++ if (!data || typeof data !== 'object' || !Array.isArray(data.services)) {
++ throw new OpenVibeError({ code: 'sdk.bad_response', message: `${cfg.discoveryUrl} is not an OpenVibe platform descriptor` });
++ }
++ const version = (data.contracts && data.contracts.version) || null;
++ const compatible = Boolean(version) && satisfies(version, cfg.contractsRange);
++ const origins = {};
++ for (const s of data.services) if (s && s.id && s.origin) origins[s.id] = trimSlash(s.origin);
++ const out = Object.freeze({
++ issuer: data.issuer || null,
++ tokenEndpoint: data.token_endpoint || null,
++ jwksUri: data.jwks_uri || null,
++ registry: data.registry || null,
++ services: data.services,
++ origins: Object.freeze(origins),
++ contractsVersion: version,
++ contractsRange: cfg.contractsRange,
++ compatible,
++ fetchedAt: new Date().toISOString(),
++ raw: data,
++ });
++ if (!compatible) {
++ const msg = `network runs openvibe-contracts ${version || '(unknown)'}, this SDK supports ${cfg.contractsRange}`;
++ if (cfg.strictContracts) throw new OpenVibeError({ code: 'sdk.incompatible_contracts', message: msg });
++ if (!shared.warned) { shared.warned = true; cfg.onWarning(msg); }
++ }
++ shared.discovery = out;
++ shared.discoveredAt = Date.now();
++ return out;
++ })().finally(() => { shared.inflight = null; });
++ }
++ return shared.inflight;
++ }
++
++ /** Origin for a service: explicit baseUrls win, then the registry. */
++ async function origin(service) {
++ if (cfg.baseUrls[service]) return cfg.baseUrls[service];
++ if (service === 'network') return cfg.network;
++ let d = cachedDiscovery();
++ if (!d && cfg.autoDiscover) d = await discover();
++ if (d && d.origins[service]) return d.origins[service];
++ throw new OpenVibeError({ code: 'sdk.unknown_service', message: `no base URL for service "${service}" (pass baseUrls.${service} or let discover() find it)` });
++ }
++
++ /** Is the service registered and running (alpha or later)? Uses the cached descriptor. */
++ async function supports(service) {
++ const d = await discover();
++ const s = d.services.find((x) => x && x.id === service);
++ return Boolean(s && ['alpha', 'beta', 'stable', 'degraded'].includes(s.status));
++ }
++
++ async function buildUrl({ url, service, baseUrl, path = '', query } = {}) {
++ let u;
++ if (url) u = String(url);
++ else {
++ const base = baseUrl ? trimSlash(baseUrl) : await origin(service || 'network');
++ u = `${base}${path.startsWith('/') ? '' : '/'}${path}`;
++ }
++ if (query) {
++ const qs = new URLSearchParams();
++ for (const [k, v] of Object.entries(query)) {
++ if (v === undefined || v === null || v === false) continue;
++ qs.set(k, v === true ? '1' : Array.isArray(v) ? v.join(',') : String(v));
++ }
++ const s = qs.toString();
++ if (s) u += (u.includes('?') ? '&' : '?') + s;
++ }
++ return u;
++ }
++
++ async function tokenFor(service, audience) {
++ if (cfg.token != null) return cfg.token;
++ if (cfg.getToken) return (await cfg.getToken({ service, audience })) || null;
++ return null;
++ }
++
++ function currentTraceparent() {
++ if (ctx.traceparent) return ctx.traceparent;
++ return typeof cfg.traceparent === 'function' ? cfg.traceparent() : cfg.traceparent;
++ }
++
++ function backoff(attempt) {
++ const base = Math.min(cfg.maxRetryDelayMs, cfg.retryDelayMs * 2 ** (attempt - 1));
++ return Math.round(base / 2 + Math.random() * base / 2);
++ }
++
++ function retryAfterMs(headers) {
++ const v = headers && headers.get('retry-after');
++ if (!v) return null;
++ const s = Number(v);
++ const ms = Number.isFinite(s) ? s * 1000 : Date.parse(v) - Date.now();
++ return Number.isFinite(ms) && ms >= 0 ? Math.min(ms, cfg.maxRetryDelayMs) : null;
++ }
++
++ /**
++ * request({ service | baseUrl | url, path, method, query, json | form | urlencoded | body,
++ * headers, token, auth, audience, idempotencyKey, idempotent, retries, timeoutMs,
++ * deadlineMs, signal, traceparent, requestId, responseType })
++ * -> { status, headers, data, requestId, traceId, traceparent, attempts }
++ */
++ async function request(opts = {}) {
++ const method = String(opts.method || 'GET').toUpperCase();
++ const url = await buildUrl(opts);
++ const retries = Math.max(0, opts.retries ?? cfg.retries);
++ let idem = opts.idempotencyKey;
++ if (idem === undefined && !IDEMPOTENT.has(method) && !opts.idempotent && retries > 0) idem = newIdempotencyKey();
++ if (idem === false || idem === undefined) idem = null;
++ const canRetry = IDEMPOTENT.has(method) || Boolean(idem) || opts.idempotent === true;
++ const span = startSpan(opts.traceparent || currentTraceparent());
++ const requestId = opts.requestId || ctx.requestId || `req_${randomHex(12)}`;
++ const timeoutMs = opts.timeoutMs ?? cfg.timeoutMs;
++ const deadline = Date.now() + (opts.deadlineMs ?? cfg.deadlineMs);
++ const service = opts.service;
++ const audience = opts.audience || (service ? audienceOf(service) : undefined);
++ const meta = { method, url, requestId, traceId: span.traceId };
++
++ let attempt = 0;
++ let reauthed = false;
++ for (;;) {
++ const headers = new Headers({ Accept: 'application/json' });
++ for (const src of [cfg.headers, ctx.headers, opts.headers]) {
++ for (const [k, v] of Object.entries(src || {})) if (v !== undefined && v !== null) headers.set(k, String(v));
++ }
++ headers.set('traceparent', span.traceparent);
++ headers.set('X-OpenVibe-Request-Id', requestId);
++ if (idem) headers.set('Idempotency-Key', idem);
++ if (opts.auth !== false && !headers.has('authorization')) {
++ const t = opts.token !== undefined ? opts.token : await tokenFor(service, audience);
++ if (t) headers.set('Authorization', `Bearer ${t}`);
++ }
++ const body = encodeBody(opts, headers);
++ const remaining = deadline - Date.now();
++ if (remaining <= 0) throw new OpenVibeError({ ...meta, code: 'sdk.deadline_exceeded', message: `${method} ${url.split('?')[0]}: deadline exceeded after ${attempt} attempt(s)` });
++
++ let out;
++ try {
++ out = await attemptOnce(cfg.fetch, url, { method, headers, body, credentials: opts.credentials ?? cfg.credentials, signal: opts.signal, responseType: opts.responseType }, Math.min(timeoutMs, remaining), meta);
++ } catch (err) {
++ if (err.code === 'sdk.aborted' || !canRetry || attempt >= retries) throw err;
++ const wait = backoff(attempt + 1);
++ if (Date.now() + wait >= deadline) throw err;
++ attempt++;
++ await sleep(wait);
++ continue;
++ }
++ const { res, data } = out;
++ if (res.status === 401 && !reauthed && opts.auth !== false && opts.token === undefined && cfg.invalidateToken) {
++ reauthed = true; // a rejected token was never acted on: refresh it and try once more
++ await cfg.invalidateToken({ service, audience });
++ continue;
++ }
++ const retryable = RETRY_STATUS.has(res.status);
++ if (!res.ok && retryable && canRetry && attempt < retries) {
++ const wait = retryAfterMs(res.headers) ?? backoff(attempt + 1);
++ if (Date.now() + wait < deadline) {
++ attempt++;
++ await sleep(wait);
++ continue;
++ }
++ }
++ const rid = res.headers.get('x-openvibe-request-id') || requestId;
++ if (!res.ok) throw OpenVibeError.fromResponse({ status: res.status, body: data, requestId: rid, traceId: span.traceId, method, url, retryable });
++ return { status: res.status, headers: res.headers, data, requestId: rid, traceId: span.traceId, traceparent: span.traceparent, attempts: attempt + 1 };
++ }
++ }
++
++ const client = {
++ request,
++ /** request() but resolves to the parsed body only. */
++ async json(opts) { return (await request(opts)).data; },
++ discover,
++ discovery: () => cachedDiscovery(),
++ origin,
++ supports,
++ url: buildUrl,
++ audienceOf,
++ traceparent: currentTraceparent,
++ /** A client that continues an incoming request's trace ({ traceparent, requestId, headers }). */
++ withContext(c = {}) {
++ const next = { ...ctx };
++ if (c.traceparent) next.traceparent = c.traceparent;
++ if (c.requestId) next.requestId = c.requestId;
++ if (c.headers) next.headers = { ...ctx.headers, ...c.headers };
++ return build(cfg, shared, next);
++ },
++ /** withContext() from an incoming request's headers (Node req.headers or a Fetch Headers). */
++ fromRequest(req) {
++ return client.withContext(contextFromHeaders(req && req.headers ? req.headers : req));
++ },
++ options: cfg,
++ };
++ return client;
++}
++
++function encodeBody(opts, headers) {
++ if (opts.json !== undefined) {
++ if (!headers.has('content-type')) headers.set('Content-Type', 'application/json');
++ return JSON.stringify(opts.json);
++ }
++ if (opts.form !== undefined) return opts.form; // FormData: fetch sets the multipart boundary
++ if (opts.urlencoded !== undefined) {
++ headers.set('Content-Type', 'application/x-www-form-urlencoded');
++ const p = opts.urlencoded instanceof URLSearchParams ? opts.urlencoded : new URLSearchParams(Object.entries(opts.urlencoded).filter(([, v]) => v !== undefined && v !== null).map(([k, v]) => [k, String(v)]));
++ return p.toString();
++ }
++ return opts.body;
++}
++
++/** One fetch with its own timeout, covering the body read too. */
++async function attemptOnce(fetchImpl, url, { method, headers, body, credentials, signal, responseType }, ms, meta) {
++ const ctrl = new AbortController();
++ let timedOut = false;
++ const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, ms);
++ const onAbort = () => ctrl.abort();
++ if (signal) {
++ if (signal.aborted) ctrl.abort();
++ else signal.addEventListener('abort', onAbort, { once: true });
++ }
++ try {
++ const init = { method, headers, body, signal: ctrl.signal };
++ if (credentials) init.credentials = credentials;
++ const res = await fetchImpl(url, init);
++ if (responseType === 'response') return { res, data: null };
++ return { res, data: await parseBody(res, responseType) };
++ } catch (err) {
++ if (signal && signal.aborted) throw new OpenVibeError({ ...meta, code: 'sdk.aborted', message: `${method} ${url.split('?')[0]}: aborted`, cause: err });
++ if (timedOut) throw new OpenVibeError({ ...meta, code: 'sdk.timeout', retryable: true, message: `${method} ${url.split('?')[0]}: no response within ${ms} ms`, cause: err });
++ if (err && err.name === 'OpenVibeError') throw err;
++ throw new OpenVibeError({ ...meta, code: 'sdk.network_error', retryable: true, message: `${method} ${url.split('?')[0]}: ${err && err.message ? err.message : 'network error'}`, cause: err });
++ } finally {
++ clearTimeout(timer);
++ if (signal) signal.removeEventListener('abort', onAbort);
++ }
++}
++
++async function parseBody(res, responseType) {
++ if (res.status === 204 || res.status === 205 || res.status === 304) return null;
++ if (responseType === 'text') return res.text();
++ if (responseType === 'arrayBuffer') return res.arrayBuffer();
++ const text = await res.text();
++ if (!text) return null;
++ const type = res.headers.get('content-type') || '';
++ if (responseType === 'json' || /[/+]json\b/.test(type)) {
++ try { return JSON.parse(text); } catch { return text; }
++ }
++ return text;
++}
++
++module.exports = { createClient, CONTRACTS_RANGE, DEFAULT_NETWORK, IDEMPOTENT_METHODS: IDEMPOTENT, RETRY_STATUS };
ddiff --git a/src/core/errors.js b/src/core/errors.js
nnew file mode 100644
iindex 0000000..11502cf
---- /dev/null
++++ b/src/core/errors.js
@@@ -0,0 +1,76 @@
++'use strict';
++/**
++ * OpenVibeError: one error type for every failed call.
++ *
++ * Services answer with RFC 9457 problem details (contract errors.problem@1: type, title, status,
++ * detail, code, request_id, trace_id). Older routes still answer { error: 'text' } and the OAuth
++ * token endpoint answers { error, error_description }; all three become the same shape here, so
++ * callers branch on `err.code` and `err.status` only.
++ *
++ * SDK-side failures use codes in the `sdk.` family: sdk.timeout, sdk.deadline_exceeded,
++ * sdk.aborted, sdk.network_error, sdk.unknown_service, sdk.incompatible_contracts, sdk.bad_response.
++ */
++
++const CODE_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$/;
++
++class OpenVibeError extends Error {
++ constructor({ message, code, status = 0, title, detail, type, requestId, traceId, errors, problem, retryable = false, cause, method, url } = {}) {
++ super(message || detail || title || code || 'OpenVibe request failed', cause ? { cause } : undefined);
++ this.name = 'OpenVibeError';
++ this.code = code || (status ? `http.${status}` : 'sdk.error');
++ this.status = status;
++ if (title) this.title = title;
++ if (detail) this.detail = detail;
++ if (type) this.type = type;
++ this.requestId = requestId || null;
++ this.traceId = traceId || null;
++ if (errors) this.errors = errors;
++ this.problem = problem || null;
++ this.retryable = Boolean(retryable);
++ if (method) this.method = method;
++ if (url) this.url = url;
++ }
++
++ /** Build from an HTTP error response body (problem+json, legacy { error }, OAuth error, or text). */
++ static fromResponse({ status, body, requestId, traceId, method, url, retryable = false }) {
++ const obj = body && typeof body === 'object' && !Array.isArray(body) ? body : null;
++ let code = null;
++ if (obj && typeof obj.code === 'string' && obj.code) code = obj.code;
++ else if (obj && typeof obj.error === 'string' && CODE_RE.test(obj.error) && obj.error.includes('_')) code = obj.error; // OAuth: invalid_client
++ const detail = (obj && (obj.detail || obj.error_description || (typeof obj.error === 'string' ? obj.error : null)))
++ || (typeof body === 'string' && body.trim() ? body.trim().slice(0, 500) : null);
++ const title = obj && typeof obj.title === 'string' ? obj.title : null;
++ return new OpenVibeError({
++ message: `${method || 'GET'} ${stripQuery(url)} -> ${status}${code ? ` ${code}` : ''}${detail ? `: ${detail}` : ''}`,
++ code: code || `http.${status}`,
++ status,
++ title,
++ detail,
++ type: obj && obj.type,
++ requestId: (obj && obj.request_id) || requestId,
++ traceId: (obj && obj.trace_id) || traceId,
++ errors: obj && Array.isArray(obj.errors) ? obj.errors : undefined,
++ problem: obj && typeof obj.code === 'string' && typeof obj.status === 'number' ? obj : null,
++ retryable,
++ method,
++ url,
++ });
++ }
++
++ toJSON() {
++ return {
++ name: this.name, code: this.code, status: this.status, title: this.title, detail: this.detail, type: this.type,
++ request_id: this.requestId, trace_id: this.traceId, errors: this.errors, message: this.message,
++ };
++ }
++}
++
++function stripQuery(url) {
++ return typeof url === 'string' ? url.split('?')[0] : '';
++}
++
++function isOpenVibeError(err) {
++ return err instanceof OpenVibeError || Boolean(err && err.name === 'OpenVibeError' && typeof err.code === 'string');
++}
++
++module.exports = { OpenVibeError, isOpenVibeError };
ddiff --git a/src/core/ids.js b/src/core/ids.js
nnew file mode 100644
iindex 0000000..a276741
---- /dev/null
++++ b/src/core/ids.js
@@@ -0,0 +1,46 @@
++'use strict';
++/**
++ * Identifiers, browser-safe (Web Crypto getRandomValues; no Node modules).
++ * ULIDs match openvibe-contracts ids.ulid(): Crockford base32, 48-bit ms time + 80-bit randomness.
++ */
++
++const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
++
++function randomBytes(n) {
++ const c = globalThis.crypto;
++ if (!c || typeof c.getRandomValues !== 'function') throw new Error('openvibe-sdk: Web Crypto (globalThis.crypto) is required');
++ return c.getRandomValues(new Uint8Array(n));
++}
++
++function randomHex(nBytes) {
++ let out = '';
++ for (const b of randomBytes(nBytes)) out += b.toString(16).padStart(2, '0');
++ return out;
++}
++
++function ulid(now = Date.now()) {
++ let time = '';
++ for (let t = now, i = 0; i < 10; i++, t = Math.floor(t / 32)) time = ALPHABET[t % 32] + time;
++ let rand = '';
++ for (const b of randomBytes(16)) rand += ALPHABET[b & 31]; // 256 is a multiple of 32: unbiased
++ return time + rand;
++}
++
++/** evt_<ULID>, the event id format of events.event-envelope@1. */
++function newEventId(now) {
++ return `evt_${ulid(now)}`;
++}
++
++/** A fresh Idempotency-Key value. */
++function newIdempotencyKey() {
++ return `idem_${ulid()}`;
++}
++
++const SUBJECT_RE = { user: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/, guest: /^gst_[0-9A-HJKMNP-TV-Z]{26}$/ };
++
++/** Is this a usr_… or gst_… subject id (the ids a service may act for)? */
++function isActingSubjectId(id) {
++ return typeof id === 'string' && (SUBJECT_RE.user.test(id) || SUBJECT_RE.guest.test(id));
++}
++
++module.exports = { ulid, newEventId, newIdempotencyKey, randomBytes, randomHex, isActingSubjectId };
ddiff --git a/src/core/index.js b/src/core/index.js
nnew file mode 100644
iindex 0000000..87e9bc0
---- /dev/null
++++ b/src/core/index.js
@@@ -0,0 +1,30 @@
++'use strict';
++/** openvibe-sdk/core: the HTTP client, errors, tracing, ids, pagination. Browser-safe. */
++const { createClient, CONTRACTS_RANGE, DEFAULT_NETWORK } = require('./client');
++const { OpenVibeError, isOpenVibeError } = require('./errors');
++const { parseTraceparent, startSpan, contextFromHeaders } = require('./trace');
++const { ulid, newEventId, newIdempotencyKey, isActingSubjectId } = require('./ids');
++const { paginate, offsetPager } = require('./paginate');
++const semver = require('./semver');
++
++const SDK_VERSION = '0.1.0';
++
++module.exports = {
++ createClient,
++ OpenVibeError,
++ isOpenVibeError,
++ paginate,
++ offsetPager,
++ parseTraceparent,
++ startSpan,
++ contextFromHeaders,
++ ulid,
++ newEventId,
++ newIdempotencyKey,
++ isActingSubjectId,
++ satisfiesRange: semver.satisfies,
++ compareVersions: semver.compare,
++ CONTRACTS_RANGE,
++ DEFAULT_NETWORK,
++ SDK_VERSION,
++};
ddiff --git a/src/core/paginate.js b/src/core/paginate.js
nnew file mode 100644
iindex 0000000..dcbf313
---- /dev/null
++++ b/src/core/paginate.js
@@@ -0,0 +1,41 @@
++'use strict';
++/**
++ * Cursor pagination as an async iterator.
++ *
++ * for await (const item of paginate((cursor) => fetchPage(cursor), { cursor: 0 })) { … }
++ *
++ * `fetchPage(cursor)` returns { items, next }; iteration stops when `next` is null/undefined or
++ * does not move. Offset APIs use `offsetPager()` to produce the same shape.
++ */
++
++async function* paginate(fetchPage, { cursor, maxPages = Infinity, maxItems = Infinity } = {}) {
++ let c = cursor;
++ let pages = 0;
++ let items = 0;
++ while (pages < maxPages) {
++ pages++;
++ const page = await fetchPage(c);
++ for (const item of (page && page.items) || []) {
++ if (items >= maxItems) return;
++ items++;
++ yield item;
++ }
++ if (!page || page.next == null || page.next === c) return;
++ c = page.next;
++ }
++}
++
++/**
++ * For limit/offset endpoints: wraps `load(offset, limit)` -> { items, total? } into a fetchPage.
++ * Stops on a short page, or when `total` is known and reached.
++ */
++function offsetPager(load, { limit = 50 } = {}) {
++ return async (offset = 0) => {
++ const { items, total } = await load(offset, limit);
++ const end = offset + items.length;
++ const more = items.length === limit && (typeof total !== 'number' || end < total);
++ return { items, next: more ? end : null };
++ };
++}
++
++module.exports = { paginate, offsetPager };
ddiff --git a/src/core/semver.js b/src/core/semver.js
nnew file mode 100644
iindex 0000000..c2bd840
---- /dev/null
++++ b/src/core/semver.js
@@@ -0,0 +1,53 @@
++'use strict';
++/**
++ * Just enough semver for version negotiation: parse, compare, and ranges made of space-separated
++ * comparators (>=, >, <=, <, =, bare), caret (^1.2.3), tilde (~1.2.3) and `||` alternatives.
++ */
++
++const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
++
++function parse(v) {
++ const m = typeof v === 'string' && v.trim().match(VERSION_RE);
++ return m ? { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] || null } : null;
++}
++
++function compare(a, b) {
++ const x = typeof a === 'string' ? parse(a) : a;
++ const y = typeof b === 'string' ? parse(b) : b;
++ if (!x || !y) throw new TypeError(`invalid version ${!x ? a : b}`);
++ for (const k of ['major', 'minor', 'patch']) if (x[k] !== y[k]) return x[k] < y[k] ? -1 : 1;
++ if (x.pre === y.pre) return 0;
++ if (!x.pre) return 1;
++ if (!y.pre) return -1;
++ return x.pre < y.pre ? -1 : 1;
++}
++
++function comparators(part) {
++ const out = [];
++ for (const tok of part.trim().split(/\s+/).filter(Boolean)) {
++ const m = tok.match(/^(>=|<=|>|<|=|\^|~)?(.+)$/);
++ const op = m[1] || '=';
++ const v = parse(m[2]);
++ if (!v) throw new TypeError(`invalid range token ${tok}`);
++ if (op === '^') {
++ const upper = v.major > 0 ? `${v.major + 1}.0.0` : v.minor > 0 ? `0.${v.minor + 1}.0` : `0.0.${v.patch + 1}`;
++ out.push(['>=', v], ['<', parse(upper)]);
++ } else if (op === '~') {
++ out.push(['>=', v], ['<', parse(`${v.major}.${v.minor + 1}.0`)]);
++ } else {
++ out.push([op, v]);
++ }
++ }
++ return out;
++}
++
++function satisfies(version, range) {
++ const v = parse(version);
++ if (!v) return false;
++ return String(range).split('||').some((part) => comparators(part).every(([op, c]) => {
++ const r = compare(v, c);
++ return op === '>=' ? r >= 0 : op === '>' ? r > 0 : op === '<=' ? r <= 0 : op === '<' ? r < 0 : r === 0;
++ }));
++}
++
++module.exports = { parse, compare, satisfies };
ddiff --git a/src/core/trace.js b/src/core/trace.js
nnew file mode 100644
iindex 0000000..4a64237
---- /dev/null
++++ b/src/core/trace.js
@@@ -0,0 +1,45 @@
++'use strict';
++/**
++ * W3C Trace Context (traceparent) propagation, browser-safe.
++ * Every SDK call is one span: it continues the caller's trace when one is given (same trace id,
++ * new span id) and starts a new trace otherwise.
++ */
++const { randomHex } = require('./ids');
++
++const TRACEPARENT_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
++
++/** Parse a traceparent header; null when absent or malformed (all-zero ids are invalid). */
++function parseTraceparent(value) {
++ const m = typeof value === 'string' && value.trim().toLowerCase().match(TRACEPARENT_RE);
++ if (!m || /^0+$/.test(m[1]) || /^0+$/.test(m[2])) return null;
++ return { traceId: m[1], parentId: m[2], flags: m[3] };
++}
++
++/** A child span of `parent` (a traceparent string), or the root of a new trace. */
++function startSpan(parent) {
++ const p = parseTraceparent(parent);
++ const traceId = p ? p.traceId : randomHex(16);
++ const spanId = randomHex(8);
++ const flags = p ? p.flags : '01';
++ return { traceId, spanId, parentId: p ? p.parentId : null, traceparent: `00-${traceId}-${spanId}-${flags}` };
++}
++
++/**
++ * Context to continue from an incoming request's headers (Node IncomingMessage headers, a Fetch
++ * Headers object, or a plain object): { traceparent, requestId }.
++ */
++function contextFromHeaders(headers) {
++ const get = (k) => {
++ if (!headers) return undefined;
++ if (typeof headers.get === 'function') return headers.get(k) ?? undefined;
++ return headers[k] ?? headers[k.toLowerCase()];
++ };
++ const tp = get('traceparent');
++ const rid = get('x-openvibe-request-id');
++ return {
++ traceparent: parseTraceparent(tp) ? String(tp).trim().toLowerCase() : undefined,
++ requestId: typeof rid === 'string' && /^[A-Za-z0-9._-]{8,128}$/.test(rid) ? rid : undefined,
++ };
++}
++
++module.exports = { parseTraceparent, startSpan, contextFromHeaders };
ddiff --git a/src/events.js b/src/events.js
nnew file mode 100644
iindex 0000000..37c2830
---- /dev/null
++++ b/src/events.js
@@@ -0,0 +1,149 @@
++'use strict';
++/**
++ * openvibe-sdk/events (server): OpenVibe.Events, the durable event backbone.
++ * Service token with audience openvibe.events; each route checks one capability:
++ * events.event.publish publish()
++ * events.event.read pull(), iterate(), get(), checkpoints
++ * events.subscription.manage subscriptions.*
++ * events.delivery.admin deliveries(), replay()
++ *
++ * Browsers use openvibe-sdk/realtime (SSE) instead.
++ */
++const crypto = require('node:crypto');
++const { isOpenVibeError } = require('./core/errors');
++const { newEventId } = require('./core/ids');
++const { startSpan } = require('./core/trace');
++const { paginate } = require('./core/paginate');
++
++// ── Webhook signatures ───────────────────────────────────────
++
++/** `sha256=<hex HMAC-SHA256 of the raw body>`, the value of X-OpenVibe-Signature. */
++function signDelivery(rawBody, secret) {
++ return `sha256=${crypto.createHmac('sha256', String(secret)).update(toBuffer(rawBody)).digest('hex')}`;
++}
++
++/** Constant-time check of a delivery's X-OpenVibe-Signature against the RAW request body. */
++function verifyDelivery(rawBody, signatureHeader, secret) {
++ if (rawBody == null || typeof signatureHeader !== 'string' || !secret) return false;
++ const expected = Buffer.from(signDelivery(rawBody, secret));
++ const given = Buffer.from(signatureHeader.trim());
++ return given.length === expected.length && crypto.timingSafeEqual(given, expected);
++}
++
++/**
++ * Verify and parse one delivery: { event, seq, subscriptionId, attempt } or null when the
++ * signature does not verify. `headers` is req.headers (or a Fetch Headers).
++ */
++function parseDelivery(rawBody, headers, secret) {
++ const get = (k) => (headers && typeof headers.get === 'function' ? headers.get(k) : headers && headers[k.toLowerCase()]);
++ if (!verifyDelivery(rawBody, get('x-openvibe-signature'), secret)) return null;
++ let body;
++ try { body = JSON.parse(toBuffer(rawBody).toString('utf8')); } catch { return null; }
++ if (!body || !body.event) return null;
++ return {
++ event: body.event,
++ seq: Number(body.seq ?? get('x-openvibe-seq')),
++ subscriptionId: get('x-openvibe-subscription-id') || null,
++ attempt: Number(get('x-openvibe-delivery-attempt')) || 1,
++ };
++}
++
++function toBuffer(raw) {
++ if (Buffer.isBuffer(raw)) return raw;
++ if (raw instanceof Uint8Array) return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength);
++ return Buffer.from(String(raw));
++}
++
++// ── Client ───────────────────────────────────────────────────
++
++function createEventsClient(client, { source, baseUrl } = {}) {
++ const call = (opts) => client.json({ service: 'events', baseUrl, audience: 'openvibe.events', ...opts });
++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
++
++ /**
++ * Fill what a producer should not have to think about: event_id (evt_<ULID>), timestamp,
++ * source (from the client config), version 1, payload {}, trace_id (from the call's trace).
++ */
++ function prepare(envelope, { traceId, now = Date.now() } = {}) {
++ if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) throw new TypeError('an event envelope must be an object');
++ const out = { ...envelope };
++ if (!out.event_id) out.event_id = newEventId(now);
++ if (!out.timestamp) out.timestamp = new Date(now).toISOString();
++ if (!out.source) out.source = source;
++ if (!out.source) throw new TypeError('envelope.source is required (or pass { source } to createEventsClient)');
++ if (out.version == null) out.version = 1;
++ if (out.payload == null) out.payload = {};
++ if (!out.trace_id && traceId) out.trace_id = traceId;
++ return out;
++ }
++
++ /**
++ * publish(envelope | envelope[], { traceparent }) -> { event_id, seq, duplicate } | { results }.
++ * Safe to retry: Events stores an event_id once and answers a repeat with the stored seq.
++ */
++ async function publish(input, { traceparent } = {}) {
++ const span = startSpan(traceparent || client.traceparent());
++ const many = Array.isArray(input);
++ const events = (many ? input : [input]).map((e) => prepare(e, { traceId: span.traceId }));
++ return call({
++ method: 'POST', path: '/api/v1/events', json: many ? { events } : events[0], traceparent: span.traceparent,
++ idempotent: true, idempotencyKey: many ? undefined : events[0].event_id,
++ });
++ }
++
++ /** One page: { events: [{ seq, event }], next_after_seq, latest_seq, gap? } */
++ function pull({ topic = '*', afterSeq = 0, limit } = {}) {
++ return call({ path: '/api/v1/events', query: { topic: Array.isArray(topic) ? topic.join(',') : topic, after_seq: afterSeq, limit } });
++ }
++
++ /**
++ * Async iterator over { seq, event } from afterSeq up to the current head. onGap({ from_seq,
++ * to_seq }) is called when retention already pruned part of the range. Keep the last page's
++ * next_after_seq (onPage) as your durable cursor: it moves past events that did not match.
++ */
++ function iterate({ topic, afterSeq = 0, limit, onGap, onPage } = {}) {
++ return paginate(async (cursor) => {
++ const page = await pull({ topic, afterSeq: cursor, limit });
++ if (page.gap && onGap) await onGap(page.gap);
++ if (onPage) await onPage(page);
++ const next = page.next_after_seq < page.latest_seq ? page.next_after_seq : null;
++ return { items: page.events, next };
++ }, { cursor: afterSeq });
++ }
++
++ const subscriptions = {
++ /** { topicPattern, endpoint, secret?, retryPolicy? } -> subscription incl. `secret` (shown once). */
++ create: ({ topicPattern, endpoint, secret, retryPolicy } = {}) => call({
++ method: 'POST', path: '/api/v1/subscriptions', idempotencyKey: false,
++ json: { topic_pattern: topicPattern, endpoint, secret, retry_policy: retryPolicy },
++ }),
++ async list() { return (await call({ path: '/api/v1/subscriptions' })).subscriptions; },
++ get: (id) => orNull(call({ path: `/api/v1/subscriptions/${encodeURIComponent(id)}` })),
++ disable: (id) => call({ method: 'POST', path: `/api/v1/subscriptions/${encodeURIComponent(id)}/disable`, idempotent: true }),
++ enable: (id) => call({ method: 'POST', path: `/api/v1/subscriptions/${encodeURIComponent(id)}/enable`, idempotent: true }),
++ };
++
++ return {
++ prepare,
++ publish,
++ pull,
++ iterate,
++ /** { seq, event } or null. */
++ get: (eventId) => orNull(call({ path: `/api/v1/events/${encodeURIComponent(eventId)}` })),
++ getCheckpoint: (topic) => call({ path: '/api/v1/checkpoints', query: { topic } }),
++ setCheckpoint: (topic, cursor) => call({ method: 'PUT', path: '/api/v1/checkpoints', json: { topic, cursor } }),
++ subscriptions,
++ subscribe: subscriptions.create,
++ /** Operators (events.delivery.admin): { deliveries, counts }. */
++ deliveries: ({ status, subscriptionId, afterSeq, limit } = {}) => call({
++ path: '/api/v1/deliveries', query: { status, subscription_id: subscriptionId, after_seq: afterSeq, limit },
++ }),
++ /** Requeue retained events for one subscription: { subscriptionId, fromSeq } | { subscriptionId, eventIds }. */
++ replay: ({ subscriptionId, fromSeq, eventIds } = {}) => call({
++ method: 'POST', path: '/api/v1/deliveries/replay', idempotencyKey: false,
++ json: { subscription_id: subscriptionId, from_seq: fromSeq, event_ids: eventIds },
++ }),
++ };
++}
++
++module.exports = { createEventsClient, verifyDelivery, signDelivery, parseDelivery };
ddiff --git a/src/identity.js b/src/identity.js
nnew file mode 100644
iindex 0000000..91f2e06
---- /dev/null
++++ b/src/identity.js
@@@ -0,0 +1,56 @@
++'use strict';
++/**
++ * openvibe-sdk/identity (server): canonical subject resolution on OpenVibe.Network.
++ * Needs a service token with identity.subject.resolve (audience openvibe.network). The routes are
++ * under /internal, which the Network serves on its host-internal address: pass that as baseUrl
++ * (or baseUrls.network) when calling from the same host.
++ *
++ * GET /internal/identity/resolve?subject_id=usr_… | ?system=live&type=user&id=123
++ * POST /internal/identity/resolve-batch { subject_ids } | { system, type, ids } (<= 500 per call)
++ */
++const { isOpenVibeError } = require('./core/errors');
++
++const BATCH = 500;
++
++function createIdentityClient(client, { baseUrl } = {}) {
++ const call = (opts) => client.json({ service: 'network', baseUrl, audience: 'openvibe.network', ...opts });
++
++ /**
++ * resolve({ subjectId }) | resolve({ system, type = 'user', id })
++ * -> { subject, network_user_id?, username, display_name, avatar_url, banned, legacy?… } | null
++ */
++ async function resolve(input = {}) {
++ const query = input.subjectId ? { subject_id: input.subjectId }
++ : { system: input.system, type: input.type || 'user', id: input.id != null ? String(input.id) : undefined };
++ if (!query.subject_id && !(query.system && query.id)) throw new TypeError('resolve: pass { subjectId } or { system, id }');
++ try {
++ return await call({ path: '/internal/identity/resolve', query });
++ } catch (err) {
++ if (isOpenVibeError(err) && err.status === 404 && err.code === 'identity.subject_not_found') return null;
++ throw err;
++ }
++ }
++
++ /**
++ * resolveBatch({ subjectIds }) | resolveBatch({ system, type = 'user', ids })
++ * -> { <id>: projection | null } (any length; split into calls of 500)
++ */
++ async function resolveBatch(input = {}) {
++ const list = input.subjectIds || input.ids;
++ if (!Array.isArray(list)) throw new TypeError('resolveBatch: pass { subjectIds } or { system, ids }');
++ if (!input.subjectIds && !input.system) throw new TypeError('resolveBatch: system is required with ids');
++ const results = {};
++ const unique = [...new Set(list.map(String))];
++ for (let i = 0; i < unique.length; i += BATCH) {
++ const chunk = unique.slice(i, i + BATCH);
++ const json = input.subjectIds ? { subject_ids: chunk } : { system: input.system, type: input.type || 'user', ids: chunk };
++ const out = await call({ method: 'POST', path: '/internal/identity/resolve-batch', json, idempotent: true });
++ Object.assign(results, out && out.results);
++ }
++ return results;
++ }
++
++ return { resolve, resolveBatch };
++}
++
++module.exports = { createIdentityClient };
ddiff --git a/src/media.js b/src/media.js
nnew file mode 100644
iindex 0000000..52a12d3
---- /dev/null
++++ b/src/media.js
@@@ -0,0 +1,97 @@
++'use strict';
++/**
++ * openvibe-sdk/media: OpenVibe.Media files (Media API v1, /api/v1/:app/files) and public URL helpers.
++ *
++ * Credentials: the app's API key (Authorization: Bearer <app key>, server side only; it is a
++ * secret) or a Network service token holding media.object.upload for the app's namespace
++ * (accepted on upload only). Media refuses a user JWT here: a browser uploads through its own
++ * app server, which names the user with X-OV-User-Id (the `actingUserId` option).
++ * Browser-safe code (FormData/Blob), but the credentials it needs belong on a server.
++ */
++const { isOpenVibeError } = require('./core/errors');
++const { paginate, offsetPager } = require('./core/paginate');
++
++const DEFAULT_PUBLIC_ORIGIN = 'https://openvibe.media';
++const enc = encodeURIComponent;
++
++/** Absolute public URLs for Media objects (no auth unless the item is private). */
++function mediaUrls(origin = DEFAULT_PUBLIC_ORIGIN) {
++ const o = String(origin).replace(/\/+$/, '');
++ return {
++ origin: o,
++ file: (key) => `${o}/f/${enc(key)}`,
++ vod: (id) => `${o}/v/${enc(id)}`,
++ clip: (id) => `${o}/c/${enc(id)}`,
++ thumbnail: (name) => `${o}/t/${enc(name)}`,
++ paste: (slug) => `${o}/p/${enc(slug)}`,
++ pasteRaw: (slug) => `${o}/p/${enc(slug)}/raw`,
++ pasteScreenshot: (slug) => `${o}/p/${enc(slug)}/screenshot`,
++ vodTranscript: (id) => `${o}/v/${enc(id)}/transcript.json`,
++ liveTranscript: (sel, { limit, app } = {}) => withQuery(`${o}/live/${enc(sel)}/transcript.json`, { limit, app }),
++ liveFrame: (sel, { width, app, format } = {}) => withQuery(`${o}/live/${enc(sel)}/frame.jpg`, { w: width, app, format }),
++ /** Make a Media-relative path (e.g. an upload's `/f/<key>`) absolute. */
++ absolute: (path) => (/^https?:\/\//.test(path) ? path : `${o}${String(path).startsWith('/') ? '' : '/'}${path}`),
++ };
++}
++
++function withQuery(u, q) {
++ const qs = new URLSearchParams(Object.entries(q).filter(([, v]) => v != null && v !== '').map(([k, v]) => [k, String(v)])).toString();
++ return qs ? `${u}?${qs}` : u;
++}
++
++function toBlob(file, contentType) {
++ if (typeof Blob !== 'undefined' && file instanceof Blob) return file;
++ if (typeof file === 'string' || file instanceof ArrayBuffer || ArrayBuffer.isView(file)) {
++ return new Blob([file], contentType ? { type: contentType } : undefined);
++ }
++ throw new TypeError('upload: pass a Blob/File, ArrayBuffer, typed array (Buffer) or string');
++}
++
++function createMediaClient(client, { app, apiKey, actingUserId, baseUrl, publicOrigin } = {}) {
++ if (!app) throw new TypeError('createMediaClient: app (the tenant id, e.g. "live") is required');
++ const base = `/api/v1/${enc(app)}`;
++ const urls = mediaUrls(publicOrigin || DEFAULT_PUBLIC_ORIGIN);
++ const call = (opts, perCall = {}) => {
++ const headers = { ...opts.headers };
++ const acting = perCall.actingUserId !== undefined ? perCall.actingUserId : actingUserId;
++ if (acting != null) headers['X-OV-User-Id'] = String(acting);
++ return client.json({ service: 'media', baseUrl, audience: 'openvibe.media', ...(apiKey ? { token: apiKey } : {}), ...opts, headers });
++ };
++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
++ const withPublic = (meta) => (meta && meta.url ? { ...meta, public_url: urls.absolute(meta.url) } : meta);
++
++ const files = {
++ /**
++ * Multipart upload (field `file`) -> { key, url, public_url, size, mime, sha256, … }.
++ * Keys are content-addressed, so a repeat upload is deduplicated; retries are safe.
++ */
++ async upload(file, { filename, contentType, userId, actingUserId: acting, signal } = {}) {
++ const blob = toBlob(file, contentType);
++ const form = new FormData();
++ if (userId != null) form.append('user_id', String(userId));
++ form.append('file', blob, filename || (file && file.name) || 'file');
++ return withPublic(await call({ method: 'POST', path: `${base}/files`, form, idempotent: true, signal }, { actingUserId: acting }));
++ },
++ /** { files, used_bytes, quota_bytes, limit, offset } */
++ async list({ limit, offset } = {}) {
++ const out = await call({ path: `${base}/files`, query: { limit, offset } });
++ return { ...out, files: (out.files || []).map(withPublic) };
++ },
++ /** Async iterator over every file of the app. */
++ iterate({ pageSize = 100 } = {}) {
++ return paginate(offsetPager(async (offset, limit) => ({ items: (await files.list({ limit, offset })).files }), { limit: pageSize }), { cursor: 0 });
++ },
++ get: async (key) => withPublic(await orNull(call({ path: `${base}/files/${enc(key)}` }))),
++ /** true when deleted, false when there was no such file. */
++ async delete(key, { actingUserId: acting } = {}) {
++ try { await call({ method: 'DELETE', path: `${base}/files/${enc(key)}` }, { actingUserId: acting }); return true; } catch (err) {
++ if (isOpenVibeError(err) && err.status === 404) return false;
++ throw err;
++ }
++ },
++ };
++
++ return { app, files, urls, upload: files.upload };
++}
++
++module.exports = { createMediaClient, mediaUrls, DEFAULT_PUBLIC_ORIGIN };
ddiff --git a/src/modules.js b/src/modules.js
nnew file mode 100644
iindex 0000000..d07c153
---- /dev/null
++++ b/src/modules.js
@@@ -0,0 +1,76 @@
++'use strict';
++/**
++ * openvibe-sdk/modules: per-subject module records on OpenVibe.Network (contracts
++ * modules.module-record@1). Every write names the revision it read (If-Match); a moved revision
++ * is 412 modules.revision_conflict, so two writers never silently overwrite each other.
++ * update(ns, fn) does the read-modify-write loop for you. Browser-safe.
++ *
++ * user GET/PUT/DELETE /api/modules/:ns, GET /api/modules, GET /api/modules/:ns/public/:subject
++ * service GET/PUT /internal/modules/:ns/:subject (token with network.modules.read / .write for ns)
++ */
++const { isOpenVibeError, OpenVibeError } = require('./core/errors');
++
++const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
++const enc = encodeURIComponent;
++
++function createModulesClient(client, { baseUrl, maxAttempts = 5 } = {}) {
++ const call = (opts) => client.json({ service: 'network', baseUrl, audience: 'openvibe.network', ...opts });
++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
++ const ifMatch = (revision) => (revision === undefined || revision === null ? undefined : `"${Number(revision)}"`);
++
++ async function readModifyWrite(read, write, fn, attempts) {
++ let last;
++ for (let i = 0; i < attempts; i++) {
++ const rec = await read();
++ const next = await fn(rec ? clone(rec.data) : undefined, rec);
++ if (next === undefined) return rec; // nothing to change
++ try {
++ return await write(next, rec ? rec.revision : 0);
++ } catch (err) {
++ if (!(isOpenVibeError(err) && err.status === 412)) throw err;
++ last = err; // someone else wrote first: read again
++ }
++ }
++ throw last || new OpenVibeError({ code: 'modules.revision_conflict', status: 412, message: 'update gave up' });
++ }
++
++ const mine = {
++ /** Every record of the signed-in subject: { subject, modules, namespaces }. */
++ list: () => call({ path: '/api/modules' }),
++ /** The record, or null when there is none yet. */
++ get: (ns) => orNull(call({ path: `/api/modules/${enc(ns)}` })),
++ /** Replace the data. revision = the one you read (0 for a new record); required for users. */
++ put(ns, data, { revision } = {}) {
++ if (revision === undefined) throw new TypeError('put: pass { revision } (0 for a new record)');
++ return call({ method: 'PUT', path: `/api/modules/${enc(ns)}`, json: { data }, headers: { 'If-Match': ifMatch(revision) } });
++ },
++ /** true when a record was deleted, false when there was none. */
++ async delete(ns) {
++ try { await call({ method: 'DELETE', path: `/api/modules/${enc(ns)}` }); return true; } catch (err) {
++ if (isOpenVibeError(err) && err.status === 404) return false;
++ throw err;
++ }
++ },
++ /** Read, apply fn(data, record) -> newData (undefined = no change), write; retries on 412. */
++ update(ns, fn, { attempts = maxAttempts } = {}) {
++ return readModifyWrite(() => mine.get(ns), (data, rev) => mine.put(ns, data, { revision: rev }), fn, attempts);
++ },
++ /** Anyone: only the namespace's public fields. */
++ publicGet: (ns, subjectId) => orNull(call({ path: `/api/modules/${enc(ns)}/public/${enc(subjectId)}`, auth: false })),
++ };
++
++ const service = {
++ get: (ns, subjectId) => orNull(call({ path: `/internal/modules/${enc(ns)}/${enc(subjectId)}` })),
++ /** The owning service may write unconditionally (no revision); pass one to guard against races. */
++ put: (ns, subjectId, data, { revision } = {}) => call({
++ method: 'PUT', path: `/internal/modules/${enc(ns)}/${enc(subjectId)}`, json: { data }, headers: { 'If-Match': ifMatch(revision) },
++ }),
++ update(ns, subjectId, fn, { attempts = maxAttempts } = {}) {
++ return readModifyWrite(() => service.get(ns, subjectId), (data, rev) => service.put(ns, subjectId, data, { revision: rev }), fn, attempts);
++ },
++ };
++
++ return { ...mine, forSubject: service };
++}
++
++module.exports = { createModulesClient };
ddiff --git a/src/realtime.js b/src/realtime.js
nnew file mode 100644
iindex 0000000..0b72b04
---- /dev/null
++++ b/src/realtime.js
@@@ -0,0 +1,218 @@
++'use strict';
++/**
++ * openvibe-sdk/realtime: the browser realtime plane inside OpenVibe.Events (ADR-005), over SSE.
++ *
++ * const sub = subscribe(['live.stream.*'], (event, { seq }) => { … }, {
++ * client, // or url: 'https://events.openvibe.network'
++ * lastEventId: savedSeq, // resume after a reload
++ * onGap: (gap) => refetchState(),
++ * });
++ * sub.close();
++ *
++ * Transport: EventSource when the environment has one and no Bearer token is needed (cookies ride
++ * along with withCredentials); otherwise fetch with a streamed body (Node, or a token). Either
++ * way it resumes from the last seq it saw (Last-Event-ID), skips anything it already delivered,
++ * and reports an `event: gap` (events missed beyond retention or replay limits) through onGap.
++ * Browser-safe.
++ */
++const { OpenVibeError } = require('./core/errors');
++
++const DEFAULT_ORIGIN = 'https://events.openvibe.network';
++const FATAL = new Set([400, 401, 403, 404]);
++const sleep = (ms, signal) => new Promise((resolve) => {
++ const t = setTimeout(resolve, ms);
++ if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });
++});
++
++function subscribe(topics, onEvent, opts = {}) {
++ const list = (Array.isArray(topics) ? topics : String(topics).split(',')).map((t) => String(t).trim()).filter(Boolean);
++ if (!list.length) throw new TypeError('subscribe: at least one topic pattern');
++ if (typeof onEvent !== 'function') throw new TypeError('subscribe: onEvent must be a function');
++ const {
++ client, url, baseUrl, lastEventId = null, onGap, onOpen, onError, token, getToken,
++ withCredentials = true, transport = 'auto', reconnectDelayMs = 3000, maxReconnectDelayMs = 30000,
++ } = opts;
++ const fetchImpl = opts.fetch || (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null);
++ const ES = opts.EventSource !== undefined ? opts.EventSource : globalThis.EventSource;
++
++ const state = { lastSeq: lastEventId != null && lastEventId !== '' ? Number(lastEventId) : null, closed: false, transport: null, connected: false };
++ const stop = new AbortController();
++ let es = null;
++ let retryMs = reconnectDelayMs;
++ let failures = 0;
++
++ const report = (err) => { if (onError) { try { onError(err); } catch { /* the error hook must not break the loop */ } } };
++
++ function dispatch(type, data, id) {
++ if (type === 'gap') {
++ let gap = null;
++ try { gap = JSON.parse(data); } catch { gap = { reason: 'unknown' }; }
++ if (onGap) { try { onGap(gap); } catch (err) { report(err); } }
++ return;
++ }
++ if (type !== 'message' || !data) return;
++ let msg;
++ try { msg = JSON.parse(data); } catch { report(new OpenVibeError({ code: 'sdk.bad_response', message: 'realtime: undecodable message' })); return; }
++ const seq = Number(id !== undefined && id !== '' ? id : msg && msg.seq);
++ if (Number.isFinite(seq)) {
++ if (state.lastSeq != null && seq <= state.lastSeq) return; // already delivered
++ state.lastSeq = seq;
++ }
++ try { onEvent(msg && msg.event, { seq }); } catch (err) { report(err); }
++ }
++
++ async function streamUrl() {
++ let base = url || baseUrl;
++ if (!base && client) {
++ try { base = await client.origin('events'); } catch { base = DEFAULT_ORIGIN; }
++ }
++ base = String(base || DEFAULT_ORIGIN).replace(/\/+$/, '');
++ const u = new URL(base.endsWith('/realtime/stream') ? base : `${base}/realtime/stream`);
++ u.searchParams.set('topics', list.join(','));
++ return u;
++ }
++
++ // A Bearer token: given here, or the client's own (a service token client, a user JWT).
++ const clientAuth = client && client.options && (client.options.token || client.options.getToken) ? client.options : null;
++ async function bearer() {
++ if (token) return token;
++ const fn = getToken || (clientAuth && clientAuth.getToken);
++ if (fn) return fn({ service: 'events', audience: client ? client.audienceOf('events') : 'openvibe.events' });
++ return clientAuth ? clientAuth.token : null;
++ }
++
++ // ── fetch transport ─────────────────────────────────────
++ async function runFetch() {
++ if (!fetchImpl) throw new TypeError('realtime: no fetch available');
++ const u = await streamUrl();
++ while (!state.closed) {
++ const ctrl = new AbortController();
++ const abort = () => ctrl.abort();
++ stop.signal.addEventListener('abort', abort, { once: true });
++ try {
++ const headers = { Accept: 'text/event-stream', 'Cache-Control': 'no-cache' };
++ if (state.lastSeq != null) headers['Last-Event-ID'] = String(state.lastSeq);
++ const t = await bearer();
++ if (t) headers.Authorization = `Bearer ${t}`;
++ const init = { headers, signal: ctrl.signal, cache: 'no-store' };
++ if (withCredentials) init.credentials = 'include';
++ const res = await fetchImpl(u.toString(), init);
++ if (!res.ok) {
++ const body = await res.text().catch(() => '');
++ let parsed = body;
++ try { parsed = JSON.parse(body); } catch { /* text */ }
++ const err = OpenVibeError.fromResponse({ status: res.status, body: parsed, method: 'GET', url: u.toString(), retryable: !FATAL.has(res.status) });
++ report(err);
++ if (FATAL.has(res.status)) { state.closed = true; return; }
++ } else {
++ failures = 0;
++ state.connected = true;
++ if (onOpen) { try { onOpen(); } catch (err) { report(err); } }
++ await readSse(res.body, dispatch, (ms) => { retryMs = ms; });
++ state.connected = false;
++ }
++ } catch (err) {
++ state.connected = false;
++ if (state.closed) return;
++ report(err && err.name === 'OpenVibeError' ? err : new OpenVibeError({ code: 'sdk.network_error', retryable: true, message: `realtime: ${err && err.message}`, cause: err }));
++ } finally {
++ stop.signal.removeEventListener('abort', abort);
++ }
++ if (state.closed) return;
++ failures++;
++ await sleep(Math.min(maxReconnectDelayMs, retryMs * 2 ** Math.max(0, failures - 1)), stop.signal);
++ }
++ }
++
++ // ── EventSource transport ───────────────────────────────
++ async function runEventSource() {
++ const u = await streamUrl();
++ const open = () => {
++ if (state.closed) return;
++ const target = new URL(u.toString());
++ if (state.lastSeq != null) target.searchParams.set('last_event_id', String(state.lastSeq));
++ es = new ES(target.toString(), { withCredentials });
++ es.onopen = () => { failures = 0; state.connected = true; if (onOpen) { try { onOpen(); } catch (err) { report(err); } } };
++ es.onmessage = (m) => dispatch('message', m.data, m.lastEventId);
++ es.addEventListener('gap', (m) => dispatch('gap', m.data));
++ es.onerror = () => {
++ state.connected = false;
++ // CONNECTING (0): the browser reconnects by itself and sends Last-Event-ID.
++ // CLOSED (2): it gave up (HTTP error); reconnect ourselves from the last seq.
++ if (es.readyState === 2 && !state.closed) {
++ report(new OpenVibeError({ code: 'sdk.network_error', retryable: true, message: 'realtime: stream closed' }));
++ failures++;
++ sleep(Math.min(maxReconnectDelayMs, reconnectDelayMs * 2 ** Math.max(0, failures - 1)), stop.signal).then(open);
++ }
++ };
++ };
++ open();
++ }
++
++ const needsHeader = Boolean(token || getToken || clientAuth);
++ const useEs = transport === 'eventsource' || (transport === 'auto' && typeof ES === 'function' && !needsHeader);
++ state.transport = useEs ? 'eventsource' : 'fetch';
++ const done = (useEs ? runEventSource() : runFetch()).catch((err) => { report(err); });
++
++ return {
++ close() {
++ state.closed = true;
++ stop.abort();
++ if (es) { try { es.close(); } catch { /* already closed */ } }
++ },
++ get lastEventId() { return state.lastSeq; },
++ get transport() { return state.transport; },
++ get connected() { return state.connected; },
++ get closed() { return state.closed; },
++ /** Resolves when the fetch loop has ended (after close() or a fatal error). */
++ done,
++ };
++}
++
++/** Minimal SSE parser over a ReadableStream<Uint8Array> (WHATWG event-stream rules). */
++async function readSse(body, dispatch, onRetry) {
++ if (!body || typeof body.getReader !== 'function') throw new TypeError('realtime: response has no readable body');
++ const reader = body.getReader();
++ const decoder = new TextDecoder();
++ let buf = '';
++ let type = '';
++ let data = [];
++ let id;
++ const line = (l) => {
++ if (l === '') {
++ if (data.length || type) dispatch(type || 'message', data.join('\n'), id);
++ type = ''; data = []; id = undefined;
++ return;
++ }
++ if (l[0] === ':') return;
++ const i = l.indexOf(':');
++ const field = i < 0 ? l : l.slice(0, i);
++ let value = i < 0 ? '' : l.slice(i + 1);
++ if (value[0] === ' ') value = value.slice(1);
++ if (field === 'event') type = value;
++ else if (field === 'data') data.push(value);
++ else if (field === 'id' && !value.includes('\0')) id = value;
++ else if (field === 'retry' && /^\d+$/.test(value)) onRetry(Number(value));
++ };
++ try {
++ for (;;) {
++ const { value, done } = await reader.read();
++ if (done) break;
++ buf += decoder.decode(value, { stream: true });
++ let m;
++ while ((m = /\r\n|\r|\n/.exec(buf))) {
++ if (m[0] === '\r' && m.index === buf.length - 1) break; // a \r\n may be split across chunks
++ line(buf.slice(0, m.index));
++ buf = buf.slice(m.index + m[0].length);
++ }
++ }
++ } finally {
++ try { reader.releaseLock(); } catch { /* stream already gone */ }
++ }
++}
++
++function createRealtimeClient(client, defaults = {}) {
++ return { subscribe: (topics, onEvent, opts = {}) => subscribe(topics, onEvent, { client, ...defaults, ...opts }) };
++}
++
++module.exports = { subscribe, createRealtimeClient, DEFAULT_ORIGIN };
ddiff --git a/src/registry.js b/src/registry.js
nnew file mode 100644
iindex 0000000..7aaf8ec
---- /dev/null
++++ b/src/registry.js
@@@ -0,0 +1,34 @@
++'use strict';
++/**
++ * openvibe-sdk/registry: the ecosystem registry on OpenVibe.Network (/api/v1/registry), built from
++ * openvibe-contracts manifests. Public, cacheable, no token. Browser-safe.
++ *
++ * const registry = createRegistryClient(client);
++ * await registry.services({ status: 'alpha' });
++ * await registry.domain('openvibe.media'); // which service answers for a host
++ */
++const { isOpenVibeError } = require('./core/errors');
++
++function createRegistryClient(client, { baseUrl } = {}) {
++ const get = (path, query) => client.json({ service: 'network', baseUrl, path: `/api/v1/registry${path}`, query, auth: false });
++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
++
++ return {
++ /** The platform descriptor (cached by the client): origins, endpoints, contracts version. */
++ descriptor: (opts) => client.discover(opts),
++ /** Service manifests with polled runtime health. */
++ async services({ status } = {}) { return (await get('/services', { status })).services; },
++ /** One manifest (+ capability_details), or null. */
++ service: (id) => orNull(get(`/services/${encodeURIComponent(id)}`)),
++ async capabilities({ owner } = {}) { return (await get('/capabilities', { owner })).capabilities; },
++ capability: (id) => orNull(get(`/capabilities/${encodeURIComponent(id)}`)),
++ async namespaces() { return (await get('/namespaces')).namespaces; },
++ /** { version, contracts: [catalog entries] } */
++ contracts: () => get('/contracts'),
++ async topics() { return (await get('/topics')).topics; },
++ /** { domain, service } for a host name, or null. */
++ domain: (host) => orNull(get(`/domains/${encodeURIComponent(String(host).toLowerCase())}`)),
++ };
++}
++
++module.exports = { createRegistryClient };
ddiff --git a/src/testing/index.js b/src/testing/index.js
nnew file mode 100644
iindex 0000000..db722cc
---- /dev/null
++++ b/src/testing/index.js
@@@ -0,0 +1,505 @@
++'use strict';
++/**
++ * openvibe-sdk/testing (Node): an in-process fake of the platform, so apps can unit-test offline.
++ *
++ * const { createMockPlatform } = require('openvibe-sdk/testing');
++ * const platform = createMockPlatform({
++ * clients: { demo: { secret: 's3cret', grants: [{ capability: 'media.object.upload', audience: 'openvibe.media', namespaces: ['demo'] }] } },
++ * mediaApps: { demo: {} },
++ * });
++ * const client = createClient({ fetch: platform.fetch, tokenProvider: createServiceTokenClient({ clientId: 'demo', clientSecret: 's3cret', fetch: platform.fetch }) });
++ *
++ * `platform.fetch` answers like the real services at their public origins:
++ * Network /.well-known/openvibe, /oauth/token (client_credentials, authorization_code with PKCE,
++ * refresh_token), /api/.well-known/jwks, /api/v1/registry/*, /api/modules/*,
++ * /internal/modules/*, /internal/identity/*
++ * Events /api/v1/events, /api/v1/subscriptions, /api/v1/checkpoints, /realtime/stream (SSE)
++ * Media /api/v1/:app/files
++ * Tokens are real RS256 JWTs signed with a key generated per platform, checked the way the real
++ * services check them (audience, capability, namespace). It is a fake: no persistence, no
++ * delivery worker, simplified visibility rules. It is stricter than the Network on one point: it
++ * verifies PKCE code_verifier when the authorization carried a challenge.
++ */
++const crypto = require('node:crypto');
++
++const DEFAULT_ORIGINS = {
++ network: 'https://openvibe.network',
++ events: 'https://events.openvibe.network',
++ media: 'https://openvibe.media',
++ community: 'https://openvibe.community',
++};
++const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
++const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
++const fromB64url = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');
++
++function ulid() {
++ let t = Date.now();
++ let time = '';
++ for (let i = 0; i < 10; i++, t = Math.floor(t / 32)) time = ULID_ALPHABET[t % 32] + time;
++ let rand = '';
++ for (const b of crypto.randomBytes(16)) rand += ULID_ALPHABET[b & 31];
++ return time + rand;
++}
++
++function topicRegex(pattern) {
++ const seg = '[a-z0-9_]+';
++ const parts = String(pattern).split('.').map((p) => (p === '*' ? `${seg}(?:\\.${seg})*` : p.replace(/[^a-z0-9_]/g, '')));
++ return new RegExp(`^${parts.join('\\.')}$`);
++}
++
++function json(status, body, headers = {}) {
++ return new Response(body === null ? null : JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', ...headers } });
++}
++function problem(status, code, detail) {
++ return new Response(JSON.stringify({ type: `https://openvibe.network/problems/${code}`, title: String(status), status, code, detail, error: detail || code }), {
++ status, headers: { 'Content-Type': 'application/problem+json' },
++ });
++}
++
++function createMockPlatform(opts = {}) {
++ const origins = { ...DEFAULT_ORIGINS, ...opts.origins };
++ const issuer = opts.issuer || origins.network;
++ const contractsVersion = opts.contractsVersion || '0.6.0';
++ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const kid = 'mock-1';
++ const jwk = { ...publicKey.export({ format: 'jwk' }), use: 'sig', alg: 'RS256', kid };
++ const jwks = { public_key: publicKey.export({ type: 'spki', format: 'pem' }), algorithm: 'RS256', keys: [jwk] };
++
++ const clients = new Map();
++ const users = new Map(); // subject_id -> user
++ const codes = new Map();
++ const refreshTokens = new Map();
++ const modules = new Map(); // `${subject}|${ns}` -> record
++ const events = []; // { seq, event, publisher }
++ const subscriptions = new Map();
++ const checkpoints = new Map();
++ const files = new Map(); // `${app}|${key}` -> meta
++ const mediaApps = new Map(Object.entries(opts.mediaApps || {}).map(([id, a]) => [id, { apiKey: (a && a.apiKey) || null }]));
++ const streams = new Set();
++ const stats = { tokenRequests: 0, requests: [] };
++ const namespaces = opts.namespaces || [{ namespace: 'demo.prefs', owner: 'demo', version: 1, writers: ['owner', 'user'], publicFields: ['theme'], quotaBytes: 4096, onOwnerRemoved: 'retain-readonly', schema: { type: 'object' } }];
++ const capabilities = opts.capabilities || [];
++
++ function sign(claims) {
++ const header = { alg: 'RS256', typ: 'JWT', kid };
++ const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}`;
++ return `${input}.${b64url(crypto.sign('RSA-SHA256', Buffer.from(input), privateKey))}`;
++ }
++ function decode(token) {
++ const parts = String(token || '').split('.');
++ if (parts.length !== 3) return null;
++ try {
++ const header = JSON.parse(fromB64url(parts[0]));
++ if (header.alg !== 'RS256') return null;
++ if (!crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, fromB64url(parts[2]))) return null;
++ const claims = JSON.parse(fromB64url(parts[1]));
++ return claims.exp && claims.exp * 1000 > Date.now() ? claims : null;
++ } catch { return null; }
++ }
++ const now = () => Math.floor(Date.now() / 1000);
++
++ function addClient(id, { secret, grants = [], redirectUris = [] } = {}) {
++ clients.set(id, { id, secret, grants: grants.map((g) => (Array.isArray(g) ? { capability: g[0], audience: g[1], namespaces: g[2] || [] } : { namespaces: [], ...g })), redirectUris });
++ }
++ function addUser(u = {}) {
++ const user = { id: u.id ?? users.size + 1, subject_id: u.subject_id || `usr_${ulid()}`, username: u.username || `user${users.size + 1}`, display_name: u.display_name, role: u.role || 'user', legacy: u.legacy || [] };
++ users.set(user.subject_id, user);
++ return user;
++ }
++ for (const [id, c] of Object.entries(opts.clients || {})) addClient(id, c);
++ for (const u of opts.users || []) addUser(u);
++
++ function signUserToken(user, { expiresInSec = 3600, audience = ['openvibe.live', 'openvibe.tools', 'openvibe.games', 'openvibe.media', 'openvibe.network'] } = {}) {
++ const u = typeof user === 'string' ? users.get(user) : user;
++ return sign({ sub: u.id, id: u.id, subject_id: u.subject_id, username: u.username, display_name: u.display_name || u.username, role: u.role || 'user', iss: issuer, aud: audience, iat: now(), exp: now() + expiresInSec });
++ }
++ function signServiceToken(clientId, { audience, capabilities: cap = [], namespaces: ns = [], expiresInSec = 300 } = {}) {
++ return sign({ iss: issuer, sub: `svc:${clientId}`, actor_type: 'service', aud: [audience], cap, ns, iat: now(), exp: now() + expiresInSec, jti: `tok_${crypto.randomBytes(8).toString('hex')}` });
++ }
++
++ /** { claims } of a verified caller, or { res } with the error to answer. */
++ function principal(req, audience, capability, namespace) {
++ const auth = req.headers.get('authorization') || '';
++ if (!auth.startsWith('Bearer ')) return { res: problem(401, 'token.missing', 'no token') };
++ const claims = decode(auth.slice(7));
++ if (!claims || claims.actor_type !== 'service') return { res: problem(401, 'token.bad_signature', 'not a valid service token') };
++ if (!(claims.aud || []).includes(audience)) return { res: problem(401, 'token.wrong_audience', `not for ${audience}`) };
++ const granted = (claims.cap || []).some((c) => c === capability || (c.endsWith('.*') && capability.startsWith(c.slice(0, -1))));
++ if (!granted) return { res: problem(403, 'capability.denied', `${capability} not granted`) };
++ if (namespace && claims.ns && claims.ns.length && !claims.ns.some((n) => n === namespace || (n.endsWith('.*') && namespace.startsWith(n.slice(0, -1))))) {
++ return { res: problem(403, 'capability.namespace_denied', `namespace ${namespace} not granted`) };
++ }
++ return { claims, service: String(claims.sub).replace(/^svc:/, '') };
++ }
++ function userOf(req) {
++ const auth = req.headers.get('authorization') || '';
++ const claims = auth.startsWith('Bearer ') ? decode(auth.slice(7)) : null;
++ return claims && !claims.actor_type ? claims : null;
++ }
++
++ // ── Network ─────────────────────────────────────────────
++ function descriptor() {
++ return {
++ name: 'OpenVibe (mock)', issuer, jwks_uri: `${origins.network}/api/.well-known/jwks`, token_endpoint: `${origins.network}/oauth/token`,
++ openid_configuration: `${origins.network}/oauth/.well-known/openid-configuration`, registry: `${origins.network}/api/v1/registry`,
++ contracts: { package: 'openvibe-contracts', version: contractsVersion },
++ services: Object.entries(origins).map(([id, origin]) => ({ id, status: 'alpha', origin })),
++ };
++ }
++ const manifests = () => opts.services || Object.entries(origins).map(([id, origin]) => ({
++ id, name: `OpenVibe.${id[0].toUpperCase()}${id.slice(1)}`, version: '0.0.0', status: 'alpha', repository: `OpenVibers/OpenVibe.${id}`,
++ domains: [new URL(origin).host], publicOrigin: origin, capabilities: capabilities.filter((c) => c.owner === id).map((c) => c.id),
++ eventsProduced: [], eventsConsumed: [], namespacesOwned: [], runtime: { status: 'up', checked_at: new Date().toISOString() },
++ }));
++
++ async function tokenEndpoint(req) {
++ stats.tokenRequests++;
++ const p = new URLSearchParams(await req.text());
++ const client = clients.get(p.get('client_id') || '');
++ if (!client || client.secret !== p.get('client_secret')) return json(401, { error: 'invalid_client', error_description: 'Invalid client credentials' });
++ const grant = p.get('grant_type');
++ if (grant === 'client_credentials') {
++ const aud = p.get('audience');
++ if (!aud) return json(400, { error: 'invalid_request', error_description: 'audience is required' });
++ const grants = client.grants.filter((g) => g.audience === aud);
++ const wanted = p.get('scope') ? p.get('scope').split(/\s+/).filter(Boolean) : null;
++ const chosen = wanted ? grants.filter((g) => wanted.includes(g.capability)) : grants;
++ if (!chosen.length || (wanted && wanted.length !== chosen.length)) return json(400, { error: 'invalid_scope', error_description: `no grants for ${aud}` });
++ const cap = chosen.map((g) => g.capability);
++ const token = signServiceToken(client.id, { audience: aud, capabilities: cap, namespaces: [...new Set(chosen.flatMap((g) => g.namespaces))] });
++ return json(200, { access_token: token, token_type: 'Bearer', expires_in: 300, scope: cap.join(' ') }, { 'Cache-Control': 'no-store' });
++ }
++ if (grant === 'authorization_code') {
++ const c = codes.get(p.get('code') || '');
++ if (!c || c.used || c.clientId !== client.id) return json(400, { error: 'invalid_grant', error_description: 'Invalid authorization code' });
++ if (c.redirectUri !== p.get('redirect_uri')) return json(400, { error: 'invalid_grant', error_description: 'Redirect URI mismatch' });
++ if (c.codeChallenge) {
++ const v = p.get('code_verifier') || '';
++ if (b64url(crypto.createHash('sha256').update(v).digest()) !== c.codeChallenge) return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
++ }
++ c.used = true;
++ return issueUserTokens(users.get(c.subjectId), c.scope);
++ }
++ if (grant === 'refresh_token') {
++ const r = refreshTokens.get(p.get('refresh_token') || '');
++ if (!r || r.clientId !== client.id) return json(400, { error: 'invalid_grant', error_description: 'Invalid refresh token' });
++ refreshTokens.delete(p.get('refresh_token'));
++ return issueUserTokens(users.get(r.subjectId), 'profile theme', client.id);
++ }
++ return json(400, { error: 'unsupported_grant_type' });
++
++ function issueUserTokens(user, scope, clientId = client.id) {
++ const refresh = crypto.randomBytes(24).toString('hex');
++ refreshTokens.set(refresh, { clientId, subjectId: user.subject_id });
++ return json(200, { access_token: signUserToken(user), refresh_token: refresh, token_type: 'Bearer', expires_in: 86400, scope, user: { id: user.id, username: user.username, subject_id: user.subject_id } });
++ }
++ }
++
++ function authorize({ clientId, redirectUri, subjectId, codeChallenge, codeChallengeMethod = 'S256', scope = 'profile theme' } = {}) {
++ if (codeChallenge && codeChallengeMethod !== 'S256') throw new Error('mock authorize: only S256');
++ const user = users.get(subjectId) || [...users.values()][0] || addUser();
++ const code = crypto.randomBytes(16).toString('hex');
++ codes.set(code, { clientId, redirectUri, subjectId: user.subject_id, codeChallenge, scope, used: false });
++ return code;
++ }
++
++ function projection(u) {
++ return u ? { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username, display_name: u.display_name || u.username, avatar_url: null, banned: false } : null;
++ }
++ function findLegacy(system, type, id) {
++ if (system === 'network' && type === 'user') return [...users.values()].find((u) => String(u.id) === String(id)) || null;
++ return [...users.values()].find((u) => u.legacy.some((l) => l.system === system && (l.type || 'user') === type && String(l.id) === String(id))) || null;
++ }
++
++ function writeModule(subject, ns, data, ifMatch, writer) {
++ const def = namespaces.find((n) => n.namespace === ns);
++ if (!def) return problem(404, 'modules.unknown_namespace', `no namespace ${ns}`);
++ if (writer === 'user' && ifMatch == null) return problem(428, 'modules.revision_required', 'send If-Match');
++ const key = `${subject}|${ns}`;
++ const cur = modules.get(key);
++ const have = cur ? cur.revision : 0;
++ if (ifMatch != null && Number(String(ifMatch).replace(/^W\//, '').replace(/"/g, '')) !== have) return problem(412, 'modules.revision_conflict', `revision is ${have}`);
++ if (!data || typeof data !== 'object' || Array.isArray(data)) return problem(422, 'modules.invalid_data', 'data must be an object');
++ const rec = { subject: { type: 'user', id: subject }, namespace: ns, version: def.version, revision: have + 1, data, updated_at: new Date().toISOString(), updated_by: writer === 'user' ? `user:${subject}` : `svc:${writer}` };
++ modules.set(key, rec);
++ return json(cur ? 200 : 201, rec, { ETag: `"${rec.revision}"` });
++ }
++
++ async function network(req, url) {
++ const path = url.pathname;
++ const m = (re) => path.match(re);
++ let r;
++ if (req.method === 'GET' && path === '/.well-known/openvibe') return json(200, descriptor());
++ if (req.method === 'POST' && path === '/oauth/token') return tokenEndpoint(req);
++ if (req.method === 'GET' && path === '/api/.well-known/jwks') return json(200, jwks);
++ if (req.method === 'GET' && path.startsWith('/api/v1/registry')) {
++ const sub = path.slice('/api/v1/registry'.length);
++ const status = url.searchParams.get('status');
++ if (sub === '/services') return json(200, { services: manifests().filter((s) => !status || s.status === status), contracts_version: contractsVersion });
++ if ((r = sub.match(/^\/services\/([^/]+)$/))) {
++ const s = manifests().find((x) => x.id === decodeURIComponent(r[1]));
++ return s ? json(200, { ...s, capability_details: capabilities.filter((c) => c.owner === s.id) }) : problem(404, 'registry.unknown_service', 'no such service');
++ }
++ if (sub === '/capabilities') return json(200, { capabilities: capabilities.filter((c) => !url.searchParams.get('owner') || c.owner === url.searchParams.get('owner')) });
++ if ((r = sub.match(/^\/capabilities\/([^/]+)$/))) {
++ const c = capabilities.find((x) => x.id === decodeURIComponent(r[1]));
++ return c ? json(200, c) : problem(404, 'registry.unknown_capability', 'no such capability');
++ }
++ if (sub === '/namespaces') return json(200, { namespaces });
++ if (sub === '/contracts') return json(200, { version: contractsVersion, contracts: [] });
++ if (sub === '/topics') return json(200, { topics: [] });
++ if ((r = sub.match(/^\/domains\/([^/]+)$/))) {
++ const d = decodeURIComponent(r[1]).toLowerCase();
++ const s = manifests().find((x) => (x.domains || []).includes(d));
++ return s ? json(200, { domain: d, service: s }) : problem(404, 'registry.unknown_domain', `no service claims ${d}`);
++ }
++ return problem(404, 'registry.not_found', 'unknown registry path');
++ }
++ if ((r = m(/^\/api\/modules\/([a-z0-9_.]+)\/public\/([^/]+)$/)) && req.method === 'GET') {
++ const def = namespaces.find((n) => n.namespace === r[1]);
++ const rec = modules.get(`${r[2]}|${r[1]}`);
++ if (!def || !rec) return problem(404, 'modules.not_found', 'no record');
++ return json(200, { subject: rec.subject, namespace: rec.namespace, version: rec.version, data: Object.fromEntries(Object.entries(rec.data).filter(([k]) => def.publicFields.includes(k))) });
++ }
++ if (path === '/api/modules' || path.startsWith('/api/modules/')) {
++ const user = userOf(req);
++ if (!user) return json(401, { error: 'Authentication required' });
++ if (path === '/api/modules') return json(200, { subject: { type: 'user', id: user.subject_id }, modules: [...modules.values()].filter((x) => x.subject.id === user.subject_id), namespaces });
++ const ns = decodeURIComponent(path.slice('/api/modules/'.length));
++ const key = `${user.subject_id}|${ns}`;
++ if (req.method === 'GET') return modules.has(key) ? json(200, modules.get(key), { ETag: `"${modules.get(key).revision}"` }) : problem(404, 'modules.not_found', 'no record yet');
++ if (req.method === 'PUT') return writeModule(user.subject_id, ns, (await req.json().catch(() => ({}))).data, req.headers.get('if-match'), 'user');
++ if (req.method === 'DELETE') return modules.delete(key) ? new Response(null, { status: 204 }) : new Response(null, { status: 404 });
++ }
++ if ((r = m(/^\/internal\/modules\/([a-z0-9_.]+)\/([A-Za-z0-9_]+)$/))) {
++ const cap = req.method === 'GET' ? 'network.modules.read' : 'network.modules.write';
++ const who = principal(req, 'openvibe.network', cap, r[1]);
++ if (who.res) return who.res;
++ if (!users.has(r[2])) return problem(404, req.method === 'GET' ? 'modules.not_found' : 'identity.subject_not_found', 'no such subject');
++ const key = `${r[2]}|${r[1]}`;
++ if (req.method === 'GET') return modules.has(key) ? json(200, modules.get(key)) : problem(404, 'modules.not_found', 'no record');
++ if (req.method === 'PUT') return writeModule(r[2], r[1], (await req.json().catch(() => ({}))).data, req.headers.get('if-match'), who.service);
++ }
++ if (path === '/internal/identity/resolve' && req.method === 'GET') {
++ const who = principal(req, 'openvibe.network', 'identity.subject.resolve');
++ if (who.res) return who.res;
++ const q = url.searchParams;
++ const u = q.get('subject_id') ? users.get(q.get('subject_id')) : findLegacy(q.get('system'), q.get('type') || 'user', q.get('id'));
++ return u ? json(200, projection(u)) : problem(404, 'identity.subject_not_found', 'no subject for that id');
++ }
++ if (path === '/internal/identity/resolve-batch' && req.method === 'POST') {
++ const who = principal(req, 'openvibe.network', 'identity.subject.resolve');
++ if (who.res) return who.res;
++ const b = await req.json().catch(() => ({}));
++ const list = b.subject_ids || b.ids || [];
++ if (list.length > 500) return problem(413, 'identity.too_many_entries', 'at most 500 per call');
++ const results = {};
++ for (const id of list) results[id] = projection(b.subject_ids ? users.get(id) : findLegacy(b.system, b.type || 'user', id));
++ return json(200, { results });
++ }
++ return json(404, { error: 'Not found' });
++ }
++
++ // ── Events ──────────────────────────────────────────────
++ function visible(viewer, e) {
++ if (e.event.visibility === 'public') return true;
++ if (viewer.kind === 'service') return true;
++ return e.event.visibility === 'subject' && viewer.kind === 'user' && (e.event.actor.id === viewer.subject || (e.event.subject.type === 'user' && e.event.subject.id === viewer.subject));
++ }
++ function publishEvent(env, publisher) {
++ const dup = events.find((e) => e.event.event_id === env.event_id);
++ if (dup) return { event_id: env.event_id, seq: dup.seq, duplicate: true };
++ const stored = { seq: events.length + 1, event: { priority: 'important', visibility: 'internal', ...env }, publisher };
++ events.push(stored);
++ for (const s of streams) s.push(stored);
++ return { event_id: env.event_id, seq: stored.seq, duplicate: false };
++ }
++
++ async function eventsService(req, url) {
++ const path = url.pathname;
++ let r;
++ if (path === '/realtime/stream' && req.method === 'GET') return realtimeStream(req, url);
++ if (path === '/api/v1/events' && req.method === 'POST') {
++ const who = principal(req, 'openvibe.events', 'events.event.publish');
++ if (who.res) return who.res;
++ const body = await req.json().catch(() => null);
++ const batch = body && Array.isArray(body.events) && body.event_id === undefined;
++ const items = batch ? body.events : [body];
++ for (const e of items) {
++ if (!e || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(e.event_id || '') || !e.event_type || !e.actor || !e.subject || !e.timestamp) return problem(422, 'events.invalid_envelope', 'envelope does not match events.event-envelope@1');
++ if (e.source !== who.service) return problem(403, 'events.source_mismatch', `source "${e.source}" is not the calling service "${who.service}"`);
++ }
++ const results = items.map((e) => publishEvent(e, who.claims.sub));
++ return json(results.some((x) => !x.duplicate) ? 201 : 200, batch ? { results } : results[0]);
++ }
++ if (path === '/api/v1/events' && req.method === 'GET') {
++ const who = principal(req, 'openvibe.events', 'events.event.read');
++ if (who.res) return who.res;
++ const pats = (url.searchParams.get('topic') || '*').split(',').map(topicRegex);
++ const after = Number(url.searchParams.get('after_seq') || 0);
++ const limit = Number(url.searchParams.get('limit') || 100);
++ const out = [];
++ let cursor = after;
++ for (const e of events) {
++ if (e.seq <= after) continue;
++ if (out.length >= limit) break;
++ cursor = e.seq;
++ if (pats.some((re) => re.test(e.event.event_type))) out.push({ seq: e.seq, event: e.event });
++ }
++ if (out.length < limit) cursor = Math.max(cursor, events.length);
++ return json(200, { events: out, next_after_seq: cursor, latest_seq: events.length });
++ }
++ if ((r = path.match(/^\/api\/v1\/events\/([^/]+)$/)) && req.method === 'GET') {
++ const who = principal(req, 'openvibe.events', 'events.event.read');
++ if (who.res) return who.res;
++ const e = events.find((x) => x.event.event_id === decodeURIComponent(r[1]));
++ return e ? json(200, { seq: e.seq, event: e.event }) : problem(404, 'events.not_found', 'no such event');
++ }
++ if (path === '/api/v1/checkpoints') {
++ const who = principal(req, 'openvibe.events', 'events.event.read');
++ if (who.res) return who.res;
++ if (req.method === 'GET') {
++ const topic = url.searchParams.get('topic');
++ return json(200, { consumer: who.service, topic, cursor: checkpoints.get(`${who.service}|${topic}`) || 0 });
++ }
++ const b = await req.json().catch(() => ({}));
++ checkpoints.set(`${who.service}|${b.topic}`, b.cursor);
++ return json(200, { consumer: who.service, topic: b.topic, cursor: b.cursor });
++ }
++ if (path.startsWith('/api/v1/subscriptions')) {
++ const who = principal(req, 'openvibe.events', 'events.subscription.manage');
++ if (who.res) return who.res;
++ const view = (s, withSecret) => ({ id: s.id, consumer: s.consumer, topic_pattern: s.topic_pattern, endpoint: s.endpoint, enabled: s.enabled, retry_policy: s.retry_policy, ...(withSecret ? { secret: s.secret } : {}) });
++ if (path === '/api/v1/subscriptions' && req.method === 'POST') {
++ const b = await req.json().catch(() => ({}));
++ if (!b.topic_pattern || !b.endpoint) return problem(422, 'events.bad_request', 'topic_pattern and endpoint are required');
++ const dup = [...subscriptions.values()].find((s) => s.consumer === who.service && s.topic_pattern === b.topic_pattern && s.endpoint === b.endpoint);
++ if (dup) return problem(409, 'events.subscription_exists', 'already subscribed');
++ const s = { id: `sub_${ulid()}`, consumer: who.service, topic_pattern: b.topic_pattern, endpoint: b.endpoint, enabled: true, retry_policy: b.retry_policy || null, secret: b.secret || `whsec_${crypto.randomBytes(32).toString('hex')}` };
++ subscriptions.set(s.id, s);
++ return json(201, view(s, true));
++ }
++ if (path === '/api/v1/subscriptions' && req.method === 'GET') return json(200, { subscriptions: [...subscriptions.values()].filter((s) => s.consumer === who.service).map((s) => view(s)) });
++ if ((r = path.match(/^\/api\/v1\/subscriptions\/([^/]+)(?:\/(enable|disable))?$/))) {
++ const s = subscriptions.get(decodeURIComponent(r[1]));
++ if (!s || s.consumer !== who.service) return problem(404, 'events.not_found', 'no such subscription');
++ if (r[2] && req.method === 'POST') s.enabled = r[2] === 'enable';
++ return json(200, view(s));
++ }
++ }
++ return json(404, { error: 'Not found' });
++ }
++
++ function realtimeStream(req, url) {
++ const pats = (url.searchParams.get('topics') || '').split(',').filter(Boolean).map(topicRegex);
++ if (!pats.length) return problem(400, 'realtime.bad_request', 'topics=<pattern> is required');
++ const auth = req.headers.get('authorization') || '';
++ const claims = auth.startsWith('Bearer ') ? decode(auth.slice(7)) : null;
++ if (auth.startsWith('Bearer ') && !claims) return problem(401, 'token.bad_signature', 'bad token');
++ const viewer = claims ? (claims.actor_type ? { kind: 'service' } : { kind: 'user', subject: claims.subject_id }) : { kind: 'anonymous' };
++ const raw = req.headers.get('last-event-id') ?? url.searchParams.get('last_event_id');
++ const last = raw != null && /^\d+$/.test(raw) ? Number(raw) : null;
++ const enc = new TextEncoder();
++ let conn;
++ const body = new ReadableStream({
++ start(controller) {
++ const send = (s) => { try { controller.enqueue(enc.encode(s)); } catch { streams.delete(conn); } };
++ conn = {
++ lastSeq: last ?? events.length,
++ push(e) {
++ if (e.seq <= conn.lastSeq || !pats.some((re) => re.test(e.event.event_type)) || !visible(viewer, e)) return;
++ conn.lastSeq = e.seq;
++ send(`id: ${e.seq}\ndata: ${JSON.stringify({ seq: e.seq, event: e.event })}\n\n`);
++ },
++ close() { streams.delete(conn); try { controller.close(); } catch { /* closed */ } },
++ };
++ send(`retry: ${opts.realtimeRetryMs ?? 50}\n: connected ${viewer.kind}\n\n`);
++ if (last != null) {
++ if (last > events.length) send(`event: gap\ndata: ${JSON.stringify({ reason: 'cursor_ahead', from_seq: events.length + 1, to_seq: last, latest_seq: events.length })}\n\n`);
++ for (const e of events) conn.push(e);
++ conn.lastSeq = Math.max(conn.lastSeq, events.length);
++ }
++ streams.add(conn);
++ if (req.signal) req.signal.addEventListener('abort', () => conn.close(), { once: true });
++ },
++ cancel() { streams.delete(conn); },
++ });
++ return new Response(body, { status: 200, headers: { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache' } });
++ }
++
++ // ── Media files ─────────────────────────────────────────
++ async function media(req, url) {
++ const r = url.pathname.match(/^\/api\/v1\/([^/]+)\/files(?:\/([^/]+))?$/);
++ if (!r) return json(404, { error: 'Not found' });
++ const appId = decodeURIComponent(r[1]);
++ const app = mediaApps.get(appId);
++ if (!app) return json(404, { error: 'Unknown app' });
++ const auth = req.headers.get('authorization') || '';
++ const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
++ const byKey = Boolean(token) && app.apiKey && token === app.apiKey;
++ if (!byKey) {
++ if (req.method !== 'POST' || r[2]) return json(401, { error: 'Authentication required' });
++ const who = principal(req, 'openvibe.media', 'media.object.upload', appId);
++ if (who.res) return who.res;
++ }
++ const actingUser = byKey ? req.headers.get('x-ov-user-id') : null;
++ const view = (f) => ({ key: f.key, app_id: f.app_id, user_id: f.user_id, original_name: f.original_name, size: f.size, mime: f.mime, sha256: f.sha256, url: `/f/${f.key}`, created_at: f.created_at });
++ if (req.method === 'POST' && !r[2]) {
++ const form = await req.formData();
++ const file = form.get('file');
++ if (!file || typeof file === 'string') return json(400, { error: 'No file uploaded (multipart field: file)' });
++ const buf = Buffer.from(await file.arrayBuffer());
++ const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
++ const name = String(file.name || 'file').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120) || 'file';
++ const key = `${sha256.slice(0, 12)}-${name}`;
++ const existing = files.get(`${appId}|${key}`);
++ if (existing) return json(200, { ...view(existing), deduplicated: true });
++ const f = { key, app_id: appId, user_id: actingUser ?? form.get('user_id') ?? null, original_name: file.name, size: buf.length, mime: file.type || 'application/octet-stream', sha256, created_at: new Date().toISOString(), bytes: buf };
++ files.set(`${appId}|${key}`, f);
++ return json(201, view(f));
++ }
++ if (req.method === 'GET' && !r[2]) {
++ const all = [...files.values()].filter((f) => f.app_id === appId);
++ const limit = Number(url.searchParams.get('limit') || 100);
++ const offset = Number(url.searchParams.get('offset') || 0);
++ return json(200, { files: all.slice(offset, offset + limit).map(view), used_bytes: all.reduce((n, f) => n + f.size, 0), quota_bytes: 0, limit, offset });
++ }
++ const f = files.get(`${appId}|${decodeURIComponent(r[2] || '')}`);
++ if (!f) return json(404, { error: 'File not found' });
++ if (req.method === 'GET') return json(200, view(f));
++ if (req.method === 'DELETE') { files.delete(`${appId}|${f.key}`); return json(200, { message: 'File deleted' }); }
++ return json(404, { error: 'Not found' });
++ }
++
++ // ── Router ──────────────────────────────────────────────
++ async function fetchImpl(input, init) {
++ const req = input instanceof Request && !init ? input : new Request(input, init);
++ const url = new URL(req.url);
++ stats.requests.push({ method: req.method, url: req.url, headers: Object.fromEntries(req.headers) });
++ const origin = url.origin;
++ if (origin === new URL(origins.network).origin) return network(req, url);
++ if (origin === new URL(origins.events).origin) return eventsService(req, url);
++ if (origin === new URL(origins.media).origin) return media(req, url);
++ throw new TypeError(`mock platform: no service at ${origin} (fetch failed)`);
++ }
++
++ return {
++ fetch: fetchImpl,
++ origins,
++ issuer,
++ keys: { privateKey, publicKey, jwks },
++ signUserToken,
++ signServiceToken,
++ addClient,
++ addUser,
++ authorize,
++ stats,
++ state: { events, subscriptions, modules, files, users, checkpoints },
++ /** Store an event as if a producer had published it (for realtime/pull tests). */
++ publishEvent: (env, publisher = 'svc:mock') => publishEvent({ event_id: `evt_${ulid()}`, version: 1, timestamp: new Date().toISOString(), payload: {}, ...env }, publisher),
++ /** End every open realtime stream (clients reconnect with Last-Event-ID). */
++ dropRealtime() { for (const s of [...streams]) s.close(); },
++ };
++}
++
++module.exports = { createMockPlatform, DEFAULT_ORIGINS };
ddiff --git a/test/auth-tokens.test.js b/test/auth-tokens.test.js
nnew file mode 100644
iindex 0000000..4f062ce
---- /dev/null
++++ b/test/auth-tokens.test.js
@@@ -0,0 +1,98 @@
++'use strict';
++/** Client-credentials token client: caching, concurrent sharing, refresh window, audiences, errors. */
++const assert = require('node:assert/strict');
++const { stubServer, send, run, sleep } = require('./helpers');
++const { createServiceTokenClient } = require('../src/auth');
++const { createClient } = require('../src/core');
++
++async function tokenServer() {
++ let issued = 0;
++ const srv = await stubServer(async (req, res, body) => {
++ if (req.url !== '/oauth/token') return send(res, 404, { error: 'nope' });
++ const p = new URLSearchParams(body.toString());
++ if (p.get('client_secret') !== 'shh') return send(res, 401, { error: 'invalid_client', error_description: 'Invalid client credentials' });
++ await sleep(20);
++ issued++;
++ send(res, 200, { access_token: `t${issued}:${p.get('audience')}:${p.get('scope') || ''}`, token_type: 'Bearer', expires_in: 300, scope: p.get('scope') || '' }, { 'Cache-Control': 'no-store' });
++ });
++ return { srv, issued: () => issued };
++}
++
++run([
++ ['POSTs client_credentials with audience and scope, caches the token', async () => {
++ const { srv, issued } = await tokenServer();
++ const tokens = createServiceTokenClient({ network: srv.url, clientId: 'live', clientSecret: 'shh', audience: 'openvibe.events', scope: ['events.event.publish', 'events.event.read'] });
++ const t = await tokens.getToken();
++ assert.equal(t, 't1:openvibe.events:events.event.publish events.event.read');
++ const p = new URLSearchParams(srv.requests[0].body.toString());
++ assert.equal(p.get('grant_type'), 'client_credentials');
++ assert.equal(p.get('client_id'), 'live');
++ assert.equal(srv.requests[0].headers['content-type'], 'application/x-www-form-urlencoded');
++ assert.equal(await tokens.getToken(), t);
++ assert.deepEqual(await tokens.authHeaders(), { Authorization: `Bearer ${t}` });
++ assert.equal(issued(), 1);
++ await srv.close();
++ }],
++
++ ['concurrent callers share one in-flight request', async () => {
++ const { srv, issued } = await tokenServer();
++ const tokens = createServiceTokenClient({ tokenUrl: `${srv.url}/oauth/token`, clientId: 'live', clientSecret: 'shh', audience: 'openvibe.network' });
++ const all = await Promise.all(Array.from({ length: 8 }, () => tokens.getToken()));
++ assert.equal(new Set(all).size, 1);
++ assert.equal(issued(), 1);
++ await srv.close();
++ }],
++
++ ['refreshes 60 s before expiry; invalidate() forces a new token', async () => {
++ const { srv, issued } = await tokenServer();
++ let clock = 1_000_000;
++ const tokens = createServiceTokenClient({ network: srv.url, clientId: 'live', clientSecret: 'shh', audience: 'openvibe.network', now: () => clock });
++ const a = await tokens.getToken();
++ clock += 239_000; // 61 s left: still fresh
++ assert.equal(await tokens.getToken(), a);
++ clock += 2_000; // 59 s left: refresh
++ const b = await tokens.getToken();
++ assert.notEqual(b, a);
++ tokens.invalidate();
++ const c = await tokens.getToken();
++ assert.notEqual(c, b);
++ assert.equal(issued(), 3);
++ await srv.close();
++ }],
++
++ ['one cache entry per audience (and per-audience scope map)', async () => {
++ const { srv, issued } = await tokenServer();
++ const tokens = createServiceTokenClient({ network: srv.url, clientId: 'live', clientSecret: 'shh', scope: { 'openvibe.media': 'media.object.upload' } });
++ const m = await tokens.getToken({ audience: 'openvibe.media' });
++ const e = await tokens.getToken({ audience: 'openvibe.events' });
++ assert.match(m, /:openvibe\.media:media\.object\.upload$/);
++ assert.match(e, /:openvibe\.events:$/);
++ assert.equal(await tokens.getToken({ audience: 'openvibe.media' }), m);
++ tokens.invalidate({ audience: 'openvibe.media' });
++ assert.notEqual(await tokens.getToken({ audience: 'openvibe.media' }), m);
++ assert.equal(await tokens.getToken({ audience: 'openvibe.events' }), e);
++ assert.equal(issued(), 3);
++ await assert.rejects(createServiceTokenClient({ network: srv.url, clientId: 'x', clientSecret: 'y' }).getToken(), TypeError);
++ await srv.close();
++ }],
++
++ ['a refused client is an OpenVibeError; a failed fetch is not cached', async () => {
++ const { srv } = await tokenServer();
++ const bad = createServiceTokenClient({ network: srv.url, clientId: 'live', clientSecret: 'wrong', audience: 'openvibe.network' });
++ await assert.rejects(bad.getToken(), (err) => err.name === 'OpenVibeError' && err.code === 'invalid_client' && err.status === 401);
++ await assert.rejects(bad.getToken());
++ assert.equal(srv.requests.length, 2);
++ await srv.close();
++ }],
++
++ ['plugged into createClient: each service gets a token for its own audience', async () => {
++ const { srv } = await tokenServer();
++ const api = await stubServer((req, res) => send(res, 200, { auth: req.headers.authorization }));
++ const tokens = createServiceTokenClient({ network: srv.url, clientId: 'live', clientSecret: 'shh' });
++ const client = createClient({ baseUrls: { media: api.url, events: api.url }, tokenProvider: tokens });
++ assert.match((await client.json({ service: 'media', path: '/' })).auth, /^Bearer t\d:openvibe\.media:/);
++ assert.match((await client.json({ service: 'events', path: '/' })).auth, /^Bearer t\d:openvibe\.events:/);
++ await srv.close();
++ await api.close();
++ }],
++]);
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
nnew file mode 100644
iindex 0000000..31b095e
---- /dev/null
++++ b/test/browser-entries.test.js
@@@ -0,0 +1,101 @@
++'use strict';
++/**
++ * No secrets or Node-only code in anything a browser bundle can reach. Resolves every export the
++ * way a bundler does with the `browser` condition, follows relative require/import edges, and
++ * fails on client_secret, node: imports, require('crypto'), process.env, or any non-relative
++ * dependency. Exports that are server-only are declared with `"browser": null`.
++ */
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const path = require('node:path');
++const { run } = require('./helpers');
++
++const ROOT = path.join(__dirname, '..');
++const pkg = require('../package.json');
++const CONDITIONS = ['browser', 'import', 'default'];
++
++const FORBIDDEN = [
++ [/client_secret/, 'mentions client_secret'],
++ [/(?:require\s*\(|from\s+|import\s*\()\s*['"]node:/, 'imports a node: module'],
++ [/require\s*\(\s*['"](?:node:)?crypto['"]\s*\)/, "require('crypto')"],
++ [/process\.env/, 'reads process.env'],
++];
++
++/** The target a browser bundler picks, or null when the export is blocked for browsers. */
++function browserTarget(entry) {
++ if (typeof entry === 'string') return entry;
++ for (const c of CONDITIONS) if (Object.prototype.hasOwnProperty.call(entry, c)) return entry[c];
++ return null;
++}
++
++function edges(src) {
++ const out = [];
++ const re = /(?:require\s*\(\s*|from\s+|import\s*\(\s*)['"]([^'"]+)['"]/g;
++ let m;
++ while ((m = re.exec(src))) out.push(m[1]);
++ return out;
++}
++
++function resolveLocal(from, spec) {
++ const base = path.resolve(path.dirname(from), spec);
++ for (const cand of [base, `${base}.js`, `${base}.mjs`, path.join(base, 'index.js')]) {
++ if (fs.existsSync(cand) && fs.statSync(cand).isFile()) return cand;
++ }
++ throw new Error(`${path.relative(ROOT, from)}: cannot resolve ${spec}`);
++}
++
++function closure(entryFile) {
++ const seen = new Set();
++ const stack = [entryFile];
++ while (stack.length) {
++ const f = stack.pop();
++ if (seen.has(f)) continue;
++ seen.add(f);
++ for (const spec of edges(fs.readFileSync(f, 'utf8'))) {
++ if (!spec.startsWith('.')) throw new Error(`${path.relative(ROOT, f)} depends on "${spec}": browser code may only require its own relative files`);
++ stack.push(resolveLocal(f, spec));
++ }
++ }
++ return [...seen];
++}
++
++run([
++ ['every browser-reachable file is free of secrets and Node-only code', async () => {
++ const scanned = new Set();
++ const blocked = [];
++ for (const [sub, entry] of Object.entries(pkg.exports)) {
++ if (sub === './package.json') continue;
++ const target = browserTarget(entry);
++ if (target === null) { blocked.push(sub); continue; }
++ for (const file of closure(path.join(ROOT, target))) scanned.add(file);
++ }
++ for (const file of [path.join(ROOT, pkg.browser)]) for (const f of closure(file)) scanned.add(f);
++ const problems = [];
++ for (const file of scanned) {
++ const src = fs.readFileSync(file, 'utf8');
++ for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
++ }
++ assert.deepEqual(problems, []);
++ assert.deepEqual(blocked.sort(), ['./events', './identity', './testing']);
++ const rel = [...scanned].map((f) => path.relative(ROOT, f));
++ for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/modules.js', 'src/registry.js']) {
++ assert.ok(rel.includes(must), `${must} is scanned`);
++ }
++ for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/identity.js', 'src/testing/index.js']) {
++ assert.ok(!rel.includes(never), `${never} is not reachable from a browser entry`);
++ }
++ console.log(` scanned ${scanned.size} files`);
++ }],
++
++ ['the scanner catches what it should', async () => {
++ const bad = "const crypto = require('crypto');\nconst k = process.env.KEY;\nimport x from 'node:fs';\nbody.client_secret = s;";
++ const hits = FORBIDDEN.filter(([re]) => re.test(bad)).map(([, why]) => why);
++ assert.equal(hits.length, 4);
++ assert.throws(() => closure(path.join(ROOT, 'src/events.js')), /may only require its own relative files|depends on "node:crypto"/);
++ }],
++
++ ['server entries really are server-only (sanity)', async () => {
++ assert.match(fs.readFileSync(path.join(ROOT, 'src/auth/tokens.js'), 'utf8'), /client_secret/);
++ assert.equal(browserTarget(pkg.exports['./auth']), './src/auth/browser.js');
++ }],
++]);
ddiff --git a/test/community.test.js b/test/community.test.js
nnew file mode 100644
iindex 0000000..61c30e6
---- /dev/null
++++ b/test/community.test.js
@@@ -0,0 +1,120 @@
++'use strict';
++/** Community pastes: acting-subject / origin / source-ref / staff headers, no retried writes. */
++const assert = require('node:assert/strict');
++const { stubServer, send, run } = require('./helpers');
++const { createClient } = require('../src/core');
++const { createCommunityClient, actingHeaders } = require('../src/community');
++
++const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const GST = 'gst_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++
++async function echo() {
++ return stubServer((req, res) => {
++ if (req.url.includes('/missing')) return send(res, 404, { error: 'Paste not found' });
++ if (req.url.includes('/busy')) return send(res, 503, { error: 'busy' });
++ if (req.method === 'GET' && req.url.startsWith('/api/pastes?')) {
++ const q = new URL(req.url, 'http://x').searchParams;
++ const offset = Number(q.get('offset'));
++ const limit = Number(q.get('limit'));
++ const all = Array.from({ length: 5 }, (_, i) => ({ id: i + 1, slug: `s${i + 1}` }));
++ return send(res, 200, { pastes: all.slice(offset, offset + limit), total: all.length, limit, offset });
++ }
++ return send(res, 200, { ok: true });
++ });
++}
++const hdr = (r, k) => r.headers[k.toLowerCase()];
++
++run([
++ ['service acting for a person sends X-OV-Subject (and the service token)', async () => {
++ const srv = await echo();
++ const community = createCommunityClient(createClient({ baseUrls: { community: srv.url }, getToken: ({ audience }) => `svc-for-${audience}` }), { actingSubject: USR });
++ await community.pastes.create({ title: 't', content: 'hello', language: 'js' });
++ const r = srv.requests[0];
++ assert.equal(r.method, 'POST');
++ assert.equal(r.url, '/api/pastes');
++ assert.equal(hdr(r, 'Authorization'), 'Bearer svc-for-openvibe.community');
++ assert.equal(hdr(r, 'X-OV-Subject'), USR);
++ assert.equal(hdr(r, 'X-OV-Origin'), undefined);
++ assert.deepEqual(JSON.parse(r.body), { title: 't', content: 'hello', language: 'js' });
++ await srv.close();
++ }],
++
++ ['origin ai sends X-OV-Origin: ai and never a subject; sourceRef and staff', async () => {
++ const srv = await echo();
++ const base = createCommunityClient(createClient({ baseUrls: { community: srv.url }, token: 'svc' }));
++ const ai = base.as({ origin: 'ai', actingSubject: USR, sourceRef: { service: 'live', type: 'stream', id: '12' } });
++ await ai.pastes.create({ content: 'summary' });
++ let r = srv.requests[0];
++ assert.equal(hdr(r, 'X-OV-Origin'), 'ai');
++ assert.equal(hdr(r, 'X-OV-Subject'), undefined, 'AI output is not attributed to a person');
++ assert.deepEqual(JSON.parse(hdr(r, 'X-OV-Source-Ref')), { service: 'live', type: 'stream', id: '12' });
++ await base.pastes.delete('abc', { actingSubject: GST, staff: true });
++ r = srv.requests[1];
++ assert.equal(r.method, 'DELETE');
++ assert.equal(hdr(r, 'X-OV-Subject'), GST);
++ assert.equal(hdr(r, 'X-OV-Staff'), '1');
++ await base.as(USR).pastes.like('abc');
++ assert.equal(hdr(srv.requests[2], 'X-OV-Subject'), USR);
++ await base.pastes.like('abc');
++ assert.equal(hdr(srv.requests[3], 'X-OV-Subject'), undefined, 'as() did not leak into the base client');
++ assert.throws(() => actingHeaders({ actingSubject: '42' }), TypeError);
++ assert.throws(() => actingHeaders({ origin: 'bot' }), TypeError);
++ assert.deepEqual(actingHeaders({ actingSubject: USR, origin: 'user' }), { 'X-OV-Origin': 'user', 'X-OV-Subject': USR });
++ await srv.close();
++ }],
++
++ ['browser mode: cookie credentials, no X-OV headers, no Authorization', async () => {
++ const srv = await echo();
++ const seen = [];
++ const fetchSpy = (url, init) => { seen.push(init); return fetch(url, init); };
++ const community = createCommunityClient(createClient({ baseUrls: { community: srv.url }, credentials: 'include', fetch: fetchSpy }));
++ await community.pastes.get('abc', { noView: true });
++ const r = srv.requests[0];
++ assert.equal(r.url, '/api/pastes/abc?no_view=1');
++ assert.equal(hdr(r, 'Authorization'), undefined);
++ assert.ok(!Object.keys(r.headers).some((k) => k.startsWith('x-ov-')));
++ assert.equal(seen[0].credentials, 'include');
++ assert.equal(await community.pastes.get('missing'), null);
++ await srv.close();
++ }],
++
++ ['writes Community does not dedupe are never retried', async () => {
++ const srv = await echo();
++ const community = createCommunityClient(createClient({ baseUrls: { community: srv.url }, retryDelayMs: 5 }));
++ await assert.rejects(community.pastes.like('busy'), { status: 503 });
++ await assert.rejects(community.pastes.comments.create('busy', { content: 'hi' }), { status: 503 });
++ const busy = srv.requests.filter((r) => r.url.includes('/busy'));
++ assert.equal(busy.length, 2, 'one request each');
++ assert.ok(busy.every((r) => !r.headers['idempotency-key']));
++ await assert.rejects(community.pastes.update('busy', { title: 'y' }), { status: 503 });
++ assert.equal(srv.requests.filter((r) => r.method === 'PUT').length, 3, 'PUT is idempotent: retried');
++ await srv.close();
++ }],
++
++ ['routes: fork, copy, versions, by-user, config, comments, screenshot multipart, iterate', async () => {
++ const srv = await echo();
++ const p = createCommunityClient(createClient({ baseUrls: { community: srv.url } })).pastes;
++ await p.fork('a1');
++ await p.copy('a1');
++ await p.versions('a1');
++ await p.byUser('ana', { limit: 5 });
++ await p.config();
++ await p.comments.list('a1', { limit: 10 });
++ await p.comments.create('a1', { content: 'nice', parent_id: 3 });
++ await p.comments.delete('a1', 9);
++ await p.create({ screenshot: new Uint8Array([137, 80, 78, 71]), filename: 'shot.png', title: 'look' });
++ const lines = srv.requests.map((r) => `${r.method} ${r.url}`);
++ assert.deepEqual(lines, [
++ 'POST /api/pastes/a1/fork', 'POST /api/pastes/a1/copy', 'GET /api/pastes/a1/versions', 'GET /api/pastes/by-user/ana?limit=5',
++ 'GET /api/pastes/config', 'GET /api/pastes/a1/comments?limit=10', 'POST /api/pastes/a1/comments', 'DELETE /api/pastes/a1/comments/9', 'POST /api/pastes',
++ ]);
++ const shot = srv.requests.at(-1);
++ assert.match(shot.headers['content-type'], /^multipart\/form-data/);
++ assert.match(shot.body.toString('latin1'), /name="title"\r\n\r\nlook/);
++ assert.match(shot.body.toString('latin1'), /name="screenshot"; filename="shot.png"\r\nContent-Type: image\/png/);
++ const slugs = [];
++ for await (const x of p.iterate({ limit: 2 })) slugs.push(x.slug);
++ assert.deepEqual(slugs, ['s1', 's2', 's3', 's4', 's5']);
++ await srv.close();
++ }],
++]);
ddiff --git a/test/core.test.js b/test/core.test.js
nnew file mode 100644
iindex 0000000..66ab377
---- /dev/null
++++ b/test/core.test.js
@@@ -0,0 +1,219 @@
++'use strict';
++/** Core client: retries, deadlines, idempotency keys, problem errors, traceparent, pagination. */
++const assert = require('node:assert/strict');
++const { stubServer, send, problem, run, sleep } = require('./helpers');
++const { createClient, OpenVibeError, isOpenVibeError, paginate, offsetPager, parseTraceparent } = require('../src/core');
++
++const TP_RE = /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/;
++const fast = { retryDelayMs: 5, maxRetryDelayMs: 20 };
++
++run([
++ ['GET is retried on 503 with the same request id and traceparent', async () => {
++ const srv = await stubServer((req, res, _b, n) => (n < 3 ? problem(res, 503, 'service.unavailable', 'busy') : send(res, 200, { ok: true })));
++ const client = createClient({ baseUrls: { media: srv.url }, ...fast });
++ const out = await client.request({ service: 'media', path: '/x' });
++ assert.equal(out.data.ok, true);
++ assert.equal(out.attempts, 3);
++ assert.equal(srv.requests.length, 3);
++ const ids = new Set(srv.requests.map((r) => r.headers['x-openvibe-request-id']));
++ const tps = new Set(srv.requests.map((r) => r.headers.traceparent));
++ assert.equal(ids.size, 1);
++ assert.equal(tps.size, 1);
++ assert.match([...tps][0], TP_RE);
++ assert.equal(out.traceId, parseTraceparent([...tps][0]).traceId);
++ assert.equal(srv.requests[0].headers['idempotency-key'], undefined, 'GET needs no key');
++ await srv.close();
++ }],
++
++ ['a POST gets one Idempotency-Key and keeps it across retries', async () => {
++ const srv = await stubServer((req, res, _b, n) => (n === 1 ? send(res, 502, 'bad gateway') : send(res, 201, { id: 7 })));
++ const client = createClient({ baseUrls: { media: srv.url }, ...fast });
++ const data = await client.json({ service: 'media', method: 'POST', path: '/x', json: { a: 1 } });
++ assert.deepEqual(data, { id: 7 });
++ assert.equal(srv.requests.length, 2);
++ const k1 = srv.requests[0].headers['idempotency-key'];
++ assert.match(k1, /^idem_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.equal(srv.requests[1].headers['idempotency-key'], k1);
++ assert.equal(srv.requests[0].headers['content-type'], 'application/json');
++ assert.equal(srv.requests[0].body.toString(), '{"a":1}');
++ await srv.close();
++ }],
++
++ ['a caller-supplied key is sent as is and makes the mutation retryable', async () => {
++ const srv = await stubServer((req, res, _b, n) => (n === 1 ? send(res, 503, '') : send(res, 200, { ok: 1 })));
++ const client = createClient({ baseUrls: { x: srv.url }, ...fast });
++ await client.json({ service: 'x', method: 'POST', path: '/y', idempotencyKey: 'order-42' });
++ assert.deepEqual(srv.requests.map((r) => r.headers['idempotency-key']), ['order-42', 'order-42']);
++ await srv.close();
++ }],
++
++ ['idempotencyKey:false or retries:0 never repeats a mutation', async () => {
++ const srv = await stubServer((req, res) => problem(res, 503, 'service.unavailable', 'busy'));
++ const client = createClient({ baseUrls: { x: srv.url }, ...fast });
++ await assert.rejects(client.json({ service: 'x', method: 'POST', path: '/a', idempotencyKey: false }), (err) => {
++ assert.ok(err instanceof OpenVibeError);
++ assert.equal(err.status, 503);
++ assert.equal(err.retryable, true);
++ return true;
++ });
++ assert.equal(srv.requests.length, 1);
++ assert.equal(srv.requests[0].headers['idempotency-key'], undefined);
++ await assert.rejects(client.json({ service: 'x', method: 'PATCH', path: '/b', retries: 0 }));
++ assert.equal(srv.requests.length, 2);
++ assert.equal(srv.requests[1].headers['idempotency-key'], undefined);
++ await srv.close();
++ }],
++
++ ['4xx is not retried', async () => {
++ const srv = await stubServer((req, res) => problem(res, 400, 'bad.request', 'nope'));
++ const client = createClient({ baseUrls: { x: srv.url }, ...fast });
++ await assert.rejects(client.json({ service: 'x', path: '/a' }), { status: 400, code: 'bad.request' });
++ assert.equal(srv.requests.length, 1);
++ await srv.close();
++ }],
++
++ ['Retry-After is honoured on 429', async () => {
++ const srv = await stubServer((req, res, _b, n) => (n === 1 ? send(res, 429, { error: 'slow down' }, { 'Retry-After': '0' }) : send(res, 200, { ok: 1 })));
++ const client = createClient({ baseUrls: { x: srv.url }, retryDelayMs: 5000 });
++ const t0 = Date.now();
++ await client.json({ service: 'x', path: '/a' });
++ assert.ok(Date.now() - t0 < 1000, 'Retry-After: 0 beats the 5 s backoff');
++ await srv.close();
++ }],
++
++ ['per-attempt timeout -> sdk.timeout; deadline bounds the whole call', async () => {
++ const srv = await stubServer(async (req, res) => { await sleep(300); send(res, 200, { late: true }); });
++ const client = createClient({ baseUrls: { x: srv.url }, ...fast });
++ await assert.rejects(client.json({ service: 'x', path: '/slow', timeoutMs: 50, retries: 0 }), (err) => {
++ assert.equal(err.code, 'sdk.timeout');
++ assert.equal(err.status, 0);
++ assert.ok(err.requestId && err.traceId);
++ return true;
++ });
++ const t0 = Date.now();
++ await assert.rejects(client.json({ service: 'x', path: '/slow', timeoutMs: 50, retries: 10, deadlineMs: 180 }), (err) => ['sdk.timeout', 'sdk.deadline_exceeded'].includes(err.code));
++ assert.ok(Date.now() - t0 < 400, `deadline held (${Date.now() - t0} ms)`);
++ await srv.close();
++ }],
++
++ ['an abort signal stops the call without retries', async () => {
++ const srv = await stubServer(async (req, res) => { await sleep(300); send(res, 200, {}); });
++ const client = createClient({ baseUrls: { x: srv.url }, ...fast });
++ const ctrl = new AbortController();
++ setTimeout(() => ctrl.abort(), 30);
++ await assert.rejects(client.json({ service: 'x', path: '/a', signal: ctrl.signal }), { code: 'sdk.aborted' });
++ assert.equal(srv.requests.length, 1);
++ await srv.close();
++ }],
++
++ ['connection refused -> retried, then sdk.network_error', async () => {
++ const srv = await stubServer((req, res) => send(res, 200, {}));
++ const dead = srv.url;
++ await srv.close();
++ const client = createClient({ baseUrls: { x: dead }, ...fast });
++ await assert.rejects(client.json({ service: 'x', path: '/a' }), (err) => err.code === 'sdk.network_error' && err.retryable === true);
++ }],
++
++ ['RFC 9457 problem -> OpenVibeError with code/status/detail/requestId/traceId', async () => {
++ const srv = await stubServer((req, res) => problem(res, 403, 'capability.denied', 'not granted', { request_id: 'req_server123', trace_id: 'a'.repeat(32), errors: [{ path: '/x', message: 'bad' }] }));
++ const client = createClient({ baseUrls: { x: srv.url }, ...fast });
++ const err = await client.json({ service: 'x', path: '/a' }).catch((e) => e);
++ assert.ok(isOpenVibeError(err));
++ assert.equal(err.code, 'capability.denied');
++ assert.equal(err.status, 403);
++ assert.equal(err.detail, 'not granted');
++ assert.equal(err.requestId, 'req_server123');
++ assert.equal(err.traceId, 'a'.repeat(32));
++ assert.deepEqual(err.errors, [{ path: '/x', message: 'bad' }]);
++ assert.equal(err.problem.code, 'capability.denied');
++ assert.equal(err.toJSON().code, 'capability.denied');
++ await srv.close();
++ }],
++
++ ['legacy { error } and OAuth errors map to stable codes', async () => {
++ const srv = await stubServer((req, res) => (req.url === '/legacy' ? send(res, 404, { error: 'File not found' }) : send(res, 401, { error: 'invalid_client', error_description: 'Invalid client credentials' })));
++ const client = createClient({ baseUrls: { x: srv.url }, ...fast });
++ const a = await client.json({ service: 'x', path: '/legacy' }).catch((e) => e);
++ assert.equal(a.code, 'http.404');
++ assert.equal(a.detail, 'File not found');
++ assert.equal(a.problem, null);
++ const b = await client.json({ service: 'x', path: '/oauth', auth: false }).catch((e) => e);
++ assert.equal(b.code, 'invalid_client');
++ assert.equal(b.detail, 'Invalid client credentials');
++ await srv.close();
++ }],
++
++ ['traceparent: continue an incoming trace with a new span; start one otherwise', async () => {
++ const srv = await stubServer((req, res) => send(res, 200, {}));
++ const client = createClient({ baseUrls: { x: srv.url } });
++ const incoming = `00-${'4bf92f3577b34da6a3ce929d0e0e4736'}-${'00f067aa0ba902b7'}-01`;
++ await client.withContext({ traceparent: incoming, requestId: 'req_incoming_1' }).json({ service: 'x', path: '/a' });
++ const sent = parseTraceparent(srv.requests[0].headers.traceparent);
++ assert.equal(sent.traceId, '4bf92f3577b34da6a3ce929d0e0e4736');
++ assert.notEqual(sent.parentId, '00f067aa0ba902b7');
++ assert.equal(srv.requests[0].headers['x-openvibe-request-id'], 'req_incoming_1');
++
++ await client.fromRequest({ headers: { traceparent: incoming } }).json({ service: 'x', path: '/b' });
++ assert.equal(parseTraceparent(srv.requests[1].headers.traceparent).traceId, '4bf92f3577b34da6a3ce929d0e0e4736');
++
++ await client.json({ service: 'x', path: '/c' });
++ await client.json({ service: 'x', path: '/d' });
++ const t3 = parseTraceparent(srv.requests[2].headers.traceparent).traceId;
++ const t4 = parseTraceparent(srv.requests[3].headers.traceparent).traceId;
++ assert.notEqual(t3, t4, 'unrelated calls start their own traces');
++ assert.notEqual(t3, '4bf92f3577b34da6a3ce929d0e0e4736');
++
++ const withGetter = createClient({ baseUrls: { x: srv.url }, traceparent: () => incoming });
++ await withGetter.json({ service: 'x', path: '/e' });
++ assert.equal(parseTraceparent(srv.requests[4].headers.traceparent).traceId, '4bf92f3577b34da6a3ce929d0e0e4736');
++ await srv.close();
++ }],
++
++ ['tokens: per-service audience, and a 401 refreshes the token once', async () => {
++ let good = 'tok-2';
++ const srv = await stubServer((req, res) => (req.headers.authorization === `Bearer ${good}` ? send(res, 200, { ok: 1 }) : problem(res, 401, 'token.expired', 'expired')));
++ const seen = [];
++ let n = 0;
++ const provider = {
++ getToken(ctx) { seen.push(ctx); n++; return `tok-${n}`; },
++ invalidate(ctx) { seen.push({ invalidated: ctx.audience }); },
++ };
++ const client = createClient({ baseUrls: { media: srv.url }, tokenProvider: provider, ...fast });
++ await client.json({ service: 'media', method: 'POST', path: '/a', idempotencyKey: false });
++ assert.deepEqual(seen, [{ service: 'media', audience: 'openvibe.media' }, { invalidated: 'openvibe.media' }, { service: 'media', audience: 'openvibe.media' }]);
++ assert.equal(srv.requests.length, 2);
++ good = 'never';
++ await assert.rejects(client.json({ service: 'media', path: '/b' }), { status: 401 });
++ assert.equal(srv.requests.length, 4, 'only one refresh per call');
++ const noAuth = await srv.requests.length;
++ await client.json({ service: 'media', path: '/c', auth: false }).catch(() => null);
++ assert.equal(srv.requests[noAuth].headers.authorization, undefined);
++ await srv.close();
++ }],
++
++ ['query encoding, 204 bodies, unknown services', async () => {
++ const srv = await stubServer((req, res) => (req.url.startsWith('/empty') ? send(res, 204, '') : send(res, 200, { url: req.url })));
++ const client = createClient({ baseUrls: { x: `${srv.url}/` }, autoDiscover: false });
++ const out = await client.json({ service: 'x', path: '/q', query: { a: 'b c', skip: undefined, no: null, list: ['x', 'y'], flag: true, off: false } });
++ assert.equal(out.url, '/q?a=b+c&list=x%2Cy&flag=1');
++ assert.equal(await client.json({ service: 'x', path: '/empty' }), null);
++ await assert.rejects(client.json({ service: 'nope', path: '/' }), { code: 'sdk.unknown_service' });
++ await srv.close();
++ }],
++
++ ['paginate + offsetPager walk every page and stop on a short one', async () => {
++ const all = Array.from({ length: 23 }, (_, i) => i);
++ const calls = [];
++ const pager = offsetPager(async (offset, limit) => { calls.push(offset); return { items: all.slice(offset, offset + limit), total: all.length }; }, { limit: 10 });
++ const got = [];
++ for await (const x of paginate(pager, { cursor: 0 })) got.push(x);
++ assert.deepEqual(got, all);
++ assert.deepEqual(calls, [0, 10, 20]);
++ const firstFive = [];
++ for await (const x of paginate(pager, { cursor: 0, maxItems: 5 })) firstFive.push(x);
++ assert.deepEqual(firstFive, [0, 1, 2, 3, 4]);
++ const cursorPages = [];
++ for await (const x of paginate(async (c) => ({ items: [c], next: c < 3 ? c + 1 : null }), { cursor: 1 })) cursorPages.push(x);
++ assert.deepEqual(cursorPages, [1, 2, 3]);
++ }],
++]);
ddiff --git a/test/esm.test.js b/test/esm.test.js
nnew file mode 100644
iindex 0000000..aaa3838
---- /dev/null
++++ b/test/esm.test.js
@@@ -0,0 +1,40 @@
++'use strict';
++/** ESM entry points match the CommonJS modules; the package resolves by name (self-reference). */
++const assert = require('node:assert/strict');
++const path = require('node:path');
++const { pathToFileURL } = require('node:url');
++const { execFileSync } = require('node:child_process');
++const { run } = require('./helpers');
++const { ENTRIES } = require('../scripts/esm');
++
++const ROOT = path.join(__dirname, '..');
++
++run([
++ ['esm/*.mjs are up to date', async () => {
++ execFileSync(process.execPath, [path.join(ROOT, 'scripts/esm.js'), '--check'], { stdio: 'pipe' });
++ }],
++
++ ['every ESM file exports exactly the CommonJS names', async () => {
++ for (const [file, target] of Object.entries(ENTRIES)) {
++ const cjs = require(path.join(ROOT, 'esm', target));
++ const esm = await import(pathToFileURL(path.join(ROOT, 'esm', file)).href);
++ const names = Object.keys(esm).filter((k) => k !== 'default').sort();
++ assert.deepEqual(names, Object.keys(cjs).sort(), file);
++ assert.equal(esm.default, cjs);
++ }
++ }],
++
++ ['every subpath resolves by package name for require and import', async () => {
++ const pkg = require('../package.json');
++ for (const sub of Object.keys(pkg.exports)) {
++ if (sub === './package.json') continue;
++ const spec = sub === '.' ? 'openvibe-sdk' : `openvibe-sdk/${sub.slice(2)}`;
++ const cjs = require(spec);
++ const esm = await import(spec);
++ assert.ok(Object.keys(cjs).length > 0, spec);
++ assert.equal(esm.default, cjs, `${spec}: import and require share one module instance`);
++ }
++ const { createClient } = await import('openvibe-sdk/core');
++ assert.equal(typeof createClient, 'function');
++ }],
++]);
ddiff --git a/test/events.test.js b/test/events.test.js
nnew file mode 100644
iindex 0000000..be45b3f
---- /dev/null
++++ b/test/events.test.js
@@@ -0,0 +1,133 @@
++'use strict';
++/** Events: publish fills the envelope, pull cursor + gaps, subscriptions, webhook signatures. */
++const assert = require('node:assert/strict');
++const crypto = require('node:crypto');
++const { stubServer, send, run } = require('./helpers');
++const { createClient, parseTraceparent } = require('../src/core');
++const { createServiceTokenClient } = require('../src/auth');
++const { createEventsClient, verifyDelivery, signDelivery, parseDelivery } = require('../src/events');
++const { createMockPlatform } = require('../src/testing');
++
++const actor = { type: 'service', id: 'live' };
++
++function setup() {
++ const platform = createMockPlatform({
++ clients: {
++ live: { secret: 'live-secret', grants: [['events.event.publish', 'openvibe.events'], ['events.event.read', 'openvibe.events'], ['events.subscription.manage', 'openvibe.events']] },
++ media: { secret: 'media-secret', grants: [['events.event.publish', 'openvibe.events']] },
++ },
++ });
++ const tokens = createServiceTokenClient({ clientId: 'live', clientSecret: 'live-secret', fetch: platform.fetch });
++ const client = createClient({ fetch: platform.fetch, tokenProvider: tokens });
++ return { platform, client, events: createEventsClient(client, { source: 'live' }) };
++}
++
++run([
++ ['publish fills event_id, timestamp, source, version, payload and trace_id', async () => {
++ const { platform, events } = setup();
++ const tp = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01';
++ const out = await events.publish({ event_type: 'live.stream.started', actor, subject: { type: 'stream', id: '12' } }, { traceparent: tp });
++ assert.equal(out.seq, 1);
++ assert.equal(out.duplicate, false);
++ const stored = platform.state.events[0].event;
++ assert.match(stored.event_id, /^evt_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.equal(stored.event_id, out.event_id);
++ assert.equal(stored.source, 'live');
++ assert.equal(stored.version, 1);
++ assert.deepEqual(stored.payload, {});
++ assert.ok(!Number.isNaN(Date.parse(stored.timestamp)));
++ assert.equal(stored.trace_id, '0af7651916cd43dd8448eb211c80319c');
++ const call = platform.stats.requests.find((r) => r.url.endsWith('/api/v1/events') && r.method === 'POST');
++ assert.equal(parseTraceparent(call.headers.traceparent).traceId, stored.trace_id, 'header and envelope agree');
++ assert.equal(call.headers['idempotency-key'], stored.event_id);
++ assert.match(call.headers.authorization, /^Bearer /);
++ }],
++
++ ['republishing the same event_id is a duplicate, never stored twice; batches work', async () => {
++ const { platform, events } = setup();
++ const env = events.prepare({ event_type: 'live.stream.ended', actor, subject: { type: 'stream', id: '1' } });
++ await events.publish(env);
++ const again = await events.publish(env);
++ assert.equal(again.duplicate, true);
++ const batch = await events.publish([
++ { event_type: 'live.chat.message', actor, subject: { type: 'room', id: 'a' }, payload: { n: 1 } },
++ { event_type: 'live.chat.message', actor, subject: { type: 'room', id: 'a' }, payload: { n: 2 } },
++ ]);
++ assert.equal(batch.results.length, 2);
++ assert.equal(platform.state.events.length, 3);
++ assert.throws(() => createEventsClient(createClient({ fetch: platform.fetch })).prepare({ event_type: 'x.y.z' }), TypeError, 'source required');
++ }],
++
++ ['a producer may only publish as itself', async () => {
++ const { events } = setup();
++ await assert.rejects(events.publish({ event_type: 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: '1' } }), { status: 403, code: 'events.source_mismatch' });
++ }],
++
++ ['pull + iterate walk the cursor across pages; onPage gives the durable cursor', async () => {
++ const { platform, events } = setup();
++ for (let i = 1; i <= 5; i++) platform.publishEvent({ event_type: i % 2 ? 'media.vod.ready' : 'live.stream.started', source: 'media', actor, subject: { type: 'vod', id: String(i) } });
++ const page = await events.pull({ topic: 'media.vod.*', afterSeq: 0, limit: 2 });
++ assert.deepEqual(page.events.map((e) => e.seq), [1, 3]);
++ assert.equal(page.next_after_seq, 3);
++ assert.equal(page.latest_seq, 5);
++ const seen = [];
++ const cursors = [];
++ for await (const { seq, event } of events.iterate({ topic: ['media.vod.*'], afterSeq: 0, limit: 2, onPage: (p) => cursors.push(p.next_after_seq) })) {
++ seen.push(seq);
++ assert.equal(event.event_type, 'media.vod.ready');
++ }
++ assert.deepEqual(seen, [1, 3, 5]);
++ assert.equal(cursors.at(-1), 5);
++ assert.equal((await events.get(platform.state.events[0].event.event_id)).seq, 1);
++ assert.equal(await events.get('evt_01J00000000000000000000000'), null);
++ await events.setCheckpoint('media.vod.*', 5);
++ assert.equal((await events.getCheckpoint('media.vod.*')).cursor, 5);
++ }],
++
++ ['iterate reports a retention gap through onGap', async () => {
++ const srv = await stubServer((req, res) => {
++ const after = Number(new URL(req.url, 'http://x').searchParams.get('after_seq'));
++ if (after === 0) return send(res, 200, { gap: { from_seq: 1, to_seq: 9 }, events: [{ seq: 10, event: { event_type: 'a.b.c' } }], next_after_seq: 10, latest_seq: 11 });
++ return send(res, 200, { events: [{ seq: 11, event: { event_type: 'a.b.c' } }], next_after_seq: 11, latest_seq: 11 });
++ });
++ const events = createEventsClient(createClient({ baseUrls: { events: srv.url }, token: 't' }), { source: 'live' });
++ const gaps = [];
++ const seqs = [];
++ for await (const e of events.iterate({ topic: 'a.*', onGap: (g) => gaps.push(g) })) seqs.push(e.seq);
++ assert.deepEqual(gaps, [{ from_seq: 1, to_seq: 9 }]);
++ assert.deepEqual(seqs, [10, 11]);
++ await srv.close();
++ }],
++
++ ['subscriptions: create (secret once), list, disable, enable', async () => {
++ const { events } = setup();
++ const sub = await events.subscribe({ topicPattern: 'media.vod.*', endpoint: 'http://127.0.0.1:3000/internal/events', retryPolicy: { max_attempts: 3 } });
++ assert.match(sub.id, /^sub_/);
++ assert.match(sub.secret, /^whsec_/);
++ const list = await events.subscriptions.list();
++ assert.equal(list.length, 1);
++ assert.equal(list[0].secret, undefined);
++ assert.equal((await events.subscriptions.disable(sub.id)).enabled, false);
++ assert.equal((await events.subscriptions.enable(sub.id)).enabled, true);
++ assert.equal(await events.subscriptions.get('sub_missing'), null);
++ await assert.rejects(events.subscribe({ topicPattern: 'media.vod.*', endpoint: 'http://127.0.0.1:3000/internal/events' }), { status: 409, code: 'events.subscription_exists' });
++ }],
++
++ ['verifyDelivery / parseDelivery check X-OpenVibe-Signature over the raw body', async () => {
++ const secret = `whsec_${'ab'.repeat(32)}`;
++ const raw = Buffer.from(JSON.stringify({ event: { event_id: 'evt_1', event_type: 'media.vod.ready' }, seq: 7 }));
++ const sig = signDelivery(raw, secret);
++ assert.equal(sig, `sha256=${crypto.createHmac('sha256', secret).update(raw).digest('hex')}`);
++ assert.equal(verifyDelivery(raw, sig, secret), true);
++ assert.equal(verifyDelivery(raw.toString(), ` ${sig} `, secret), true);
++ assert.equal(verifyDelivery(new Uint8Array(raw), sig, secret), true);
++ assert.equal(verifyDelivery(raw, sig, 'other-secret'), false);
++ assert.equal(verifyDelivery(Buffer.from(raw.toString().replace('7', '8')), sig, secret), false);
++ assert.equal(verifyDelivery(raw, 'sha256=00', secret), false);
++ assert.equal(verifyDelivery(raw, undefined, secret), false);
++ assert.equal(verifyDelivery(raw, sig, ''), false);
++ const d = parseDelivery(raw, { 'x-openvibe-signature': sig, 'x-openvibe-subscription-id': 'sub_1', 'x-openvibe-delivery-attempt': '2' }, secret);
++ assert.deepEqual(d, { event: { event_id: 'evt_1', event_type: 'media.vod.ready' }, seq: 7, subscriptionId: 'sub_1', attempt: 2 });
++ assert.equal(parseDelivery(raw, new Headers({ 'X-OpenVibe-Signature': 'sha256=bad' }), secret), null);
++ }],
++]);
ddiff --git a/test/helpers.js b/test/helpers.js
nnew file mode 100644
iindex 0000000..826c0f2
---- /dev/null
++++ b/test/helpers.js
@@@ -0,0 +1,71 @@
++'use strict';
++/** Shared test helpers: a local HTTP stub server and a tiny async test runner. */
++const http = require('node:http');
++
++/**
++ * stubServer(async (req, res, body) => { … }) -> { url, requests, close }
++ * `requests` records { method, url, headers, body } for every call.
++ */
++async function stubServer(handler) {
++ const requests = [];
++ const sockets = new Set();
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', async () => {
++ const body = Buffer.concat(chunks);
++ requests.push({ method: req.method, url: req.url, headers: req.headers, body });
++ try {
++ await handler(req, res, body, requests.length);
++ } catch (err) {
++ if (!res.headersSent) { res.statusCode = 500; res.end(String(err && err.stack)); }
++ }
++ });
++ });
++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const url = `http://127.0.0.1:${server.address().port}`;
++ return {
++ url,
++ requests,
++ server,
++ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(() => r()); }),
++ };
++}
++
++function send(res, status, body, headers = {}) {
++ const isObj = body !== undefined && body !== null && typeof body === 'object' && !Buffer.isBuffer(body);
++ res.writeHead(status, { ...(isObj ? { 'Content-Type': 'application/json' } : {}), ...headers });
++ res.end(isObj ? JSON.stringify(body) : body);
++}
++
++function problem(res, status, code, detail, extra = {}) {
++ res.writeHead(status, { 'Content-Type': 'application/problem+json' });
++ res.end(JSON.stringify({ type: `https://openvibe.network/problems/${code}`, title: 'Error', status, code, detail, error: detail, ...extra }));
++}
++
++/** Run named async tests in order; exit 1 on the first failure with its stack. */
++async function run(tests) {
++ for (const [name, fn] of tests) {
++ try {
++ await fn();
++ console.log(` ok ${name}`);
++ } catch (err) {
++ console.error(` FAIL ${name}\n${err && err.stack ? err.stack : err}`);
++ process.exit(1);
++ }
++ }
++}
++
++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
++
++async function waitFor(fn, { timeoutMs = 3000, stepMs = 10 } = {}) {
++ const end = Date.now() + timeoutMs;
++ for (;;) {
++ if (await fn()) return;
++ if (Date.now() > end) throw new Error('waitFor: timed out');
++ await sleep(stepMs);
++ }
++}
++
++module.exports = { stubServer, send, problem, run, sleep, waitFor };
ddiff --git a/test/identity.test.js b/test/identity.test.js
nnew file mode 100644
iindex 0000000..3810afb
---- /dev/null
++++ b/test/identity.test.js
@@@ -0,0 +1,45 @@
++'use strict';
++/** Identity: subject resolution with a service token; 404 -> null; batches split at 500. */
++const assert = require('node:assert/strict');
++const { stubServer, send, run } = require('./helpers');
++const { createClient } = require('../src/core');
++const { createServiceTokenClient } = require('../src/auth');
++const { createIdentityClient } = require('../src/identity');
++const { createMockPlatform } = require('../src/testing');
++
++run([
++ ['resolve by subject id or legacy id against the mock Network', async () => {
++ const platform = createMockPlatform({
++ clients: { community: { secret: 'c', grants: [['identity.subject.resolve', 'openvibe.network']] }, tools: { secret: 't', grants: [['media.object.upload', 'openvibe.media']] } },
++ users: [{ id: 5, username: 'ana', legacy: [{ system: 'live', id: 900 }] }],
++ });
++ const ana = [...platform.state.users.values()][0];
++ const client = createClient({ fetch: platform.fetch, tokenProvider: createServiceTokenClient({ clientId: 'community', clientSecret: 'c', fetch: platform.fetch }) });
++ const identity = createIdentityClient(client);
++ assert.equal((await identity.resolve({ subjectId: ana.subject_id })).username, 'ana');
++ assert.deepEqual((await identity.resolve({ system: 'live', id: 900 })).subject, { type: 'user', id: ana.subject_id });
++ assert.equal(await identity.resolve({ subjectId: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' }), null);
++ await assert.rejects(identity.resolve({}), TypeError);
++ const batch = await identity.resolveBatch({ system: 'network', ids: [5, 6] });
++ assert.equal(batch['5'].username, 'ana');
++ assert.equal(batch['6'], null);
++
++ const denied = createIdentityClient(createClient({ fetch: platform.fetch, tokenProvider: createServiceTokenClient({ clientId: 'tools', clientSecret: 't', fetch: platform.fetch }) }));
++ await assert.rejects(denied.resolve({ subjectId: ana.subject_id }), (err) => err.code === 'invalid_scope' || err.status === 403, 'no grant for openvibe.network');
++ }],
++
++ ['resolveBatch splits into calls of 500 and dedupes ids', async () => {
++ const srv = await stubServer((req, res, body) => {
++ const b = JSON.parse(body.toString());
++ send(res, 200, { results: Object.fromEntries(b.subject_ids.map((id) => [id, { subject: { type: 'user', id } }])) });
++ });
++ const identity = createIdentityClient(createClient({ baseUrls: { network: srv.url }, token: 'svc' }));
++ const ids = Array.from({ length: 1201 }, (_, i) => `usr_${String(i).padStart(26, '0')}`);
++ const out = await identity.resolveBatch({ subjectIds: [...ids, ids[0]] });
++ assert.equal(Object.keys(out).length, 1201);
++ assert.deepEqual(srv.requests.map((r) => JSON.parse(r.body.toString()).subject_ids.length), [500, 500, 201]);
++ assert.ok(srv.requests.every((r) => r.url === '/internal/identity/resolve-batch' && r.headers.authorization === 'Bearer svc'));
++ await assert.rejects(identity.resolveBatch({ ids: [1] }), TypeError);
++ await srv.close();
++ }],
++]);
ddiff --git a/test/jwt.test.js b/test/jwt.test.js
nnew file mode 100644
iindex 0000000..19fd67f
---- /dev/null
++++ b/test/jwt.test.js
@@@ -0,0 +1,81 @@
++'use strict';
++/** Offline RS256 verification of Network user tokens. */
++const assert = require('node:assert/strict');
++const crypto = require('node:crypto');
++const { stubServer, send, run } = require('./helpers');
++const { verifyUserToken } = require('../src/auth');
++
++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
++function sign(claims, key, header = { alg: 'RS256', typ: 'JWT', kid: 'k1' }) {
++ const input = `${b64(header)}.${b64(claims)}`;
++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), key).toString('base64url')}`;
++}
++const pair = () => crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++const good = pair();
++const other = pair();
++const jwk = (pub, kid) => ({ ...pub.export({ format: 'jwk' }), use: 'sig', alg: 'RS256', kid });
++const jwks = { keys: [jwk(good.publicKey, 'k1')] };
++const now = () => Math.floor(Date.now() / 1000);
++const claims = (over = {}) => ({ sub: 42, id: 42, subject_id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', username: 'ana', role: 'user', iss: 'https://openvibe.network', aud: ['openvibe.live', 'openvibe.network'], iat: now(), exp: now() + 600, ...over });
++
++run([
++ ['a good token verifies and returns claims with subject_id', async () => {
++ const c = await verifyUserToken(sign(claims(), good.privateKey), { jwks, issuer: 'https://openvibe.network', audience: 'openvibe.live' });
++ assert.equal(c.subject_id, 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ');
++ assert.equal(c.username, 'ana');
++ const pem = good.publicKey.export({ type: 'spki', format: 'pem' });
++ assert.equal((await verifyUserToken(sign(claims(), good.privateKey), { publicKey: pem })).sub, 42);
++ assert.equal((await verifyUserToken(sign(claims(), good.privateKey, { alg: 'RS256' }), { jwks: { public_key: pem } })).sub, 42, 'Network legacy public_key shape');
++ }],
++
++ ['expired (beyond skew) is token.expired', async () => {
++ await assert.rejects(verifyUserToken(sign(claims({ exp: now() - 120 }), good.privateKey), { jwks }), { code: 'token.expired', status: 401 });
++ assert.ok(await verifyUserToken(sign(claims({ exp: now() - 10 }), good.privateKey), { jwks }), 'within the 30 s skew');
++ await assert.rejects(verifyUserToken(sign(claims({ exp: undefined }), good.privateKey), { jwks }), { code: 'token.expired' });
++ await assert.rejects(verifyUserToken(sign(claims({ nbf: now() + 600 }), good.privateKey), { jwks }), { code: 'token.not_yet_valid' });
++ }],
++
++ ['a token signed by another key is token.bad_signature', async () => {
++ await assert.rejects(verifyUserToken(sign(claims(), other.privateKey), { jwks }), { code: 'token.bad_signature' });
++ const tampered = sign(claims(), good.privateKey).split('.');
++ tampered[1] = b64(claims({ role: 'admin' }));
++ await assert.rejects(verifyUserToken(tampered.join('.'), { jwks }), { code: 'token.bad_signature' });
++ }],
++
++ ['alg none / HS256 / garbage are refused before any key is used', async () => {
++ const none = `${b64({ alg: 'none', typ: 'JWT' })}.${b64(claims())}.`;
++ await assert.rejects(verifyUserToken(none, { jwks }), { code: 'token.malformed' });
++ const noneSig = `${b64({ alg: 'none' })}.${b64(claims())}.c2ln`;
++ await assert.rejects(verifyUserToken(noneSig, { jwks }), { code: 'token.malformed' });
++ const pem = good.publicKey.export({ type: 'spki', format: 'pem' });
++ const hsInput = `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(claims())}`;
++ const hs = `${hsInput}.${crypto.createHmac('sha256', pem).update(hsInput).digest('base64url')}`;
++ await assert.rejects(verifyUserToken(hs, { publicKey: pem }), { code: 'token.malformed' }, 'key-confusion attack');
++ await assert.rejects(verifyUserToken('not.a.jwt', { jwks }), { code: 'token.malformed' });
++ await assert.rejects(verifyUserToken(undefined, { jwks }), { code: 'token.malformed' });
++ }],
++
++ ['issuer, audience and service-principal tokens', async () => {
++ await assert.rejects(verifyUserToken(sign(claims(), good.privateKey), { jwks, issuer: 'https://evil.example' }), { code: 'token.wrong_issuer' });
++ await assert.rejects(verifyUserToken(sign(claims(), good.privateKey), { jwks, audience: 'openvibe.games' }), { code: 'token.wrong_audience' });
++ const svc = sign({ iss: 'https://openvibe.network', sub: 'svc:live', actor_type: 'service', aud: ['openvibe.network'], cap: [], iat: now(), exp: now() + 300, jti: 'tok_1' }, good.privateKey);
++ await assert.rejects(verifyUserToken(svc, { jwks }), { code: 'token.not_user' });
++ assert.equal((await verifyUserToken(svc, { jwks, allowServiceTokens: true })).sub, 'svc:live');
++ await assert.rejects(verifyUserToken(sign(claims(), good.privateKey), { jwks: { keys: [] } }), { code: 'token.no_key' });
++ }],
++
++ ['a JWKS URL is fetched once, cached, and refetched for an unknown kid', async () => {
++ let current = { keys: [jwk(good.publicKey, 'k1')] };
++ const srv = await stubServer((req, res) => send(res, 200, current));
++ const url = `${srv.url}/api/.well-known/jwks`;
++ await verifyUserToken(sign(claims(), good.privateKey), { jwks: url });
++ await verifyUserToken(sign(claims(), good.privateKey), { jwks: url });
++ assert.equal(srv.requests.length, 1);
++ current = { keys: [jwk(good.publicKey, 'k1'), jwk(other.publicKey, 'k2')] }; // rotation
++ const c = await verifyUserToken(sign(claims(), other.privateKey, { alg: 'RS256', kid: 'k2' }), { jwks: url });
++ assert.equal(c.sub, 42);
++ assert.equal(srv.requests.length, 2);
++ await assert.rejects(verifyUserToken(sign(claims(), other.privateKey, { alg: 'RS256', kid: 'k1' }), { jwks: url }), { code: 'token.bad_signature' });
++ await srv.close();
++ }],
++]);
ddiff --git a/test/media.test.js b/test/media.test.js
nnew file mode 100644
iindex 0000000..8750bed
---- /dev/null
++++ b/test/media.test.js
@@@ -0,0 +1,109 @@
++'use strict';
++/** Media files: multipart shape, credentials, acting user, 404s, public URL helpers. */
++const assert = require('node:assert/strict');
++const { stubServer, send, run } = require('./helpers');
++const { createClient } = require('../src/core');
++const { createMediaClient, mediaUrls } = require('../src/media');
++
++/** Split a multipart body into { name, filename, contentType, value } parts. */
++function parseMultipart(body, contentType) {
++ const boundary = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/i);
++ assert.ok(boundary, 'multipart boundary in Content-Type');
++ const b = boundary[1] || boundary[2];
++ const text = body.toString('latin1');
++ assert.ok(text.trimEnd().endsWith(`--${b}--`), 'closing boundary');
++ return text.split(`--${b}`).slice(1, -1).map((chunk) => {
++ const [head, ...rest] = chunk.replace(/^\r\n/, '').split('\r\n\r\n');
++ const value = rest.join('\r\n\r\n').replace(/\r\n$/, '');
++ const disp = head.match(/content-disposition: form-data; name="([^"]+)"(?:; filename="([^"]*)")?/i);
++ const type = head.match(/content-type: ([^\r\n]+)/i);
++ return { name: disp[1], filename: disp[2], contentType: type && type[1], value };
++ });
++}
++
++run([
++ ['upload sends one multipart `file` part with filename and type, and the app key', async () => {
++ const srv = await stubServer((req, res) => send(res, 201, { key: '0123456789ab-hello.txt', app_id: 'demo', url: '/f/0123456789ab-hello.txt', size: 11, mime: 'text/plain' }));
++ const media = createMediaClient(createClient({ baseUrls: { media: srv.url } }), { app: 'demo', apiKey: 'app-key-1', publicOrigin: 'https://media.example' });
++ const out = await media.files.upload(Buffer.from('hello world'), { filename: 'hello.txt', contentType: 'text/plain', userId: 7 });
++ const r = srv.requests[0];
++ assert.equal(r.method, 'POST');
++ assert.equal(r.url, '/api/v1/demo/files');
++ assert.equal(r.headers.authorization, 'Bearer app-key-1');
++ assert.match(r.headers['content-type'], /^multipart\/form-data; boundary=/);
++ const parts = parseMultipart(r.body, r.headers['content-type']);
++ assert.deepEqual(parts.map((p) => p.name), ['user_id', 'file']);
++ assert.equal(parts[0].value, '7');
++ assert.equal(parts[1].filename, 'hello.txt');
++ assert.equal(parts[1].contentType, 'text/plain');
++ assert.equal(parts[1].value, 'hello world');
++ assert.equal(out.public_url, 'https://media.example/f/0123456789ab-hello.txt');
++ await srv.close();
++ }],
++
++ ['a Blob/File keeps its own name and type; strings and typed arrays work', async () => {
++ const srv = await stubServer((req, res) => send(res, 201, { key: 'k', url: '/f/k' }));
++ const media = createMediaClient(createClient({ baseUrls: { media: srv.url }, token: 'svc-token' }), { app: 'live' });
++ await media.upload(new File([new Uint8Array([1, 2, 3])], 'clip.bin', { type: 'application/octet-stream' }));
++ await media.upload('plain text');
++ await media.upload(new Uint8Array([104, 105]), { filename: 'hi.txt' });
++ const [a, b, c] = srv.requests.map((r) => parseMultipart(r.body, r.headers['content-type']).find((p) => p.name === 'file'));
++ assert.equal(a.filename, 'clip.bin');
++ assert.equal(a.contentType, 'application/octet-stream');
++ assert.equal(Buffer.from(a.value, 'latin1').toString('hex'), '010203');
++ assert.equal(b.filename, 'file');
++ assert.equal(b.value, 'plain text');
++ assert.equal(c.filename, 'hi.txt');
++ assert.equal(srv.requests[0].headers.authorization, 'Bearer svc-token', 'the client token (service token) when there is no app key');
++ await assert.rejects(media.upload({ not: 'a file' }), TypeError);
++ await srv.close();
++ }],
++
++ ['upload retries a transient failure (content-addressed keys dedupe)', async () => {
++ const srv = await stubServer((req, res, _b, n) => (n === 1 ? send(res, 503, '') : send(res, 200, { key: 'k', url: '/f/k', deduplicated: true })));
++ const media = createMediaClient(createClient({ baseUrls: { media: srv.url }, retryDelayMs: 5 }), { app: 'live', apiKey: 'k' });
++ assert.equal((await media.upload('x')).deduplicated, true);
++ assert.equal(srv.requests.length, 2);
++ assert.equal(parseMultipart(srv.requests[1].body, srv.requests[1].headers['content-type']).find((p) => p.name === 'file').value, 'x');
++ await srv.close();
++ }],
++
++ ['X-OV-User-Id names the acting user; list/get/delete', async () => {
++ const srv = await stubServer((req, res) => {
++ if (req.method === 'GET' && req.url.startsWith('/api/v1/live/files?')) return send(res, 200, { files: [{ key: 'a', url: '/f/a' }], used_bytes: 1, quota_bytes: 0, limit: 100, offset: 0 });
++ if (req.url.endsWith('/missing')) return send(res, 404, { error: 'File not found' });
++ if (req.method === 'GET') return send(res, 200, { key: 'a', url: '/f/a' });
++ return send(res, 200, { message: 'File deleted' });
++ });
++ const media = createMediaClient(createClient({ baseUrls: { media: srv.url } }), { app: 'live', apiKey: 'k', actingUserId: 12 });
++ const list = await media.files.list({ limit: 100 });
++ assert.equal(list.files[0].public_url, 'https://openvibe.media/f/a');
++ assert.equal(srv.requests[0].headers['x-ov-user-id'], '12');
++ assert.equal((await media.files.get('a')).key, 'a');
++ assert.equal(await media.files.get('missing'), null);
++ assert.equal(await media.files.delete('a', { actingUserId: 99 }), true);
++ assert.equal(srv.requests.at(-1).headers['x-ov-user-id'], '99');
++ assert.equal(await media.files.delete('missing'), false);
++ const all = [];
++ for await (const f of media.files.iterate()) all.push(f.key);
++ assert.deepEqual(all, ['a']);
++ await srv.close();
++ }],
++
++ ['public URL helpers', async () => {
++ const u = mediaUrls();
++ assert.equal(u.file('abc-x y.png'), 'https://openvibe.media/f/abc-x%20y.png');
++ assert.equal(u.vod(12), 'https://openvibe.media/v/12');
++ assert.equal(u.clip('c1'), 'https://openvibe.media/c/c1');
++ assert.equal(u.thumbnail('t.jpg'), 'https://openvibe.media/t/t.jpg');
++ assert.equal(u.paste('Ab12'), 'https://openvibe.media/p/Ab12');
++ assert.equal(u.pasteRaw('Ab12'), 'https://openvibe.media/p/Ab12/raw');
++ assert.equal(u.pasteScreenshot('Ab12'), 'https://openvibe.media/p/Ab12/screenshot');
++ assert.equal(u.vodTranscript(3), 'https://openvibe.media/v/3/transcript.json');
++ assert.equal(u.liveFrame('@ana', { width: 320 }), 'https://openvibe.media/live/%40ana/frame.jpg?w=320');
++ assert.equal(u.liveTranscript('whip', { limit: 5, app: 'live' }), 'https://openvibe.media/live/whip/transcript.json?limit=5&app=live');
++ assert.equal(mediaUrls('http://127.0.0.1:4100/').absolute('/f/k'), 'http://127.0.0.1:4100/f/k');
++ assert.equal(u.absolute('https://cdn.example/x'), 'https://cdn.example/x');
++ assert.throws(() => createMediaClient(createClient(), {}), TypeError);
++ }],
++]);
ddiff --git a/test/modules.test.js b/test/modules.test.js
nnew file mode 100644
iindex 0000000..3fd68bf
---- /dev/null
++++ b/test/modules.test.js
@@@ -0,0 +1,99 @@
++'use strict';
++/** User modules: If-Match revisions and update() read-modify-write with retry on 412. */
++const assert = require('node:assert/strict');
++const { stubServer, send, problem, run } = require('./helpers');
++const { createClient } = require('../src/core');
++const { createModulesClient } = require('../src/modules');
++
++/** A Network stub holding one record, with a hook to make another writer win a race. */
++async function modulesStub() {
++ const state = { rec: null, raceOnce: 0 };
++ const srv = await stubServer((req, res, body) => {
++ const m = req.url.match(/^\/(api|internal)\/modules\/([a-z._]+)(?:\/([A-Za-z0-9_]+))?$/);
++ if (!m) return send(res, 404, { error: 'Not found' });
++ if (req.method === 'GET') return state.rec ? send(res, 200, state.rec, { ETag: `"${state.rec.revision}"` }) : problem(res, 404, 'modules.not_found', 'no record yet');
++ if (req.method === 'DELETE') { const had = Boolean(state.rec); state.rec = null; return send(res, had ? 204 : 404, ''); }
++ if (state.raceOnce > 0) { // someone else writes between our read and our write
++ state.raceOnce--;
++ state.rec = { ...(state.rec || { namespace: m[2], version: 1, subject: { type: 'user', id: 'usr_x' } }), revision: (state.rec ? state.rec.revision : 0) + 1, data: { ...(state.rec ? state.rec.data : {}), other: true } };
++ }
++ const want = req.headers['if-match'] === undefined ? undefined : Number(req.headers['if-match'].replace(/"/g, ''));
++ const have = state.rec ? state.rec.revision : 0;
++ if (want !== undefined && want !== have) return problem(res, 412, 'modules.revision_conflict', `revision is ${have}, not ${want}`);
++ state.rec = { subject: { type: 'user', id: 'usr_x' }, namespace: m[2], version: 1, revision: have + 1, data: JSON.parse(body.toString()).data, updated_at: new Date().toISOString() };
++ return send(res, have ? 200 : 201, state.rec, { ETag: `"${state.rec.revision}"` });
++ });
++ return { srv, state };
++}
++
++run([
++ ['get -> null before the first write; put requires a revision', async () => {
++ const { srv } = await modulesStub();
++ const modules = createModulesClient(createClient({ network: srv.url, token: 'user-jwt' }));
++ assert.equal(await modules.get('chat.preferences'), null);
++ assert.throws(() => modules.put('chat.preferences', { a: 1 }), TypeError);
++ const rec = await modules.put('chat.preferences', { a: 1 }, { revision: 0 });
++ assert.equal(rec.revision, 1);
++ assert.equal(srv.requests.at(-1).headers['if-match'], '"0"');
++ assert.equal(srv.requests.at(-1).headers.authorization, 'Bearer user-jwt');
++ await assert.rejects(modules.put('chat.preferences', { a: 2 }, { revision: 0 }), { status: 412, code: 'modules.revision_conflict' });
++ assert.equal(await modules.delete('chat.preferences'), true);
++ assert.equal(await modules.delete('chat.preferences'), false);
++ await srv.close();
++ }],
++
++ ['update() re-reads and reapplies fn after a 412', async () => {
++ const { srv, state } = await modulesStub();
++ const modules = createModulesClient(createClient({ network: srv.url, token: 't' }));
++ await modules.put('chat.preferences', { count: 1 }, { revision: 0 });
++ state.raceOnce = 1;
++ const seen = [];
++ const rec = await modules.update('chat.preferences', (data, record) => {
++ seen.push({ data, revision: record && record.revision });
++ return { ...data, count: data.count + 1 };
++ });
++ assert.equal(seen.length, 2, 'fn ran again on the fresh record');
++ assert.deepEqual(seen[0], { data: { count: 1 }, revision: 1 });
++ assert.deepEqual(seen[1], { data: { count: 1, other: true }, revision: 2 });
++ assert.deepEqual(rec.data, { count: 2, other: true }, 'the other writer\'s change survived');
++ assert.equal(rec.revision, 3);
++ const puts = srv.requests.filter((r) => r.method === 'PUT').map((r) => r.headers['if-match']);
++ assert.deepEqual(puts, ['"0"', '"1"', '"2"']);
++ await srv.close();
++ }],
++
++ ['update() on a missing record writes with If-Match 0; undefined from fn writes nothing', async () => {
++ const { srv } = await modulesStub();
++ const modules = createModulesClient(createClient({ network: srv.url, token: 't' }));
++ const rec = await modules.update('chat.preferences', (data) => ({ fresh: data === undefined }));
++ assert.deepEqual(rec.data, { fresh: true });
++ const before = srv.requests.length;
++ assert.equal((await modules.update('chat.preferences', () => undefined)).revision, 1);
++ assert.equal(srv.requests.length, before + 1, 'read only');
++ await srv.close();
++ }],
++
++ ['update() gives up after `attempts` conflicts with the 412', async () => {
++ const { srv, state } = await modulesStub();
++ const modules = createModulesClient(createClient({ network: srv.url, token: 't' }));
++ state.raceOnce = 10;
++ await assert.rejects(modules.update('chat.preferences', (d) => ({ ...d, x: 1 }), { attempts: 3 }), { status: 412 });
++ assert.equal(srv.requests.filter((r) => r.method === 'PUT').length, 3);
++ await srv.close();
++ }],
++
++ ['service side: /internal/modules/:ns/:subject, unconditional or guarded', async () => {
++ const { srv, state } = await modulesStub();
++ const modules = createModulesClient(createClient({ baseUrls: { network: srv.url }, token: 'svc-token' }));
++ const rec = await modules.forSubject.put('live.profile', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', { bio: 'hi' });
++ assert.equal(srv.requests[0].url, '/internal/modules/live.profile/usr_01JAB2C3D4E5F6G7H8J9K0MNPQ');
++ assert.equal(srv.requests[0].headers['if-match'], undefined);
++ assert.equal(rec.revision, 1);
++ state.raceOnce = 1;
++ const up = await modules.forSubject.update('live.profile', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', (d) => ({ ...d, bio: 'hello' }));
++ assert.equal(up.data.bio, 'hello');
++ assert.equal(up.data.other, true);
++ assert.equal((await modules.forSubject.get('live.profile', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ')).revision, 3);
++ await srv.close();
++ }],
++]);
ddiff --git a/test/pack.test.js b/test/pack.test.js
nnew file mode 100644
iindex 0000000..9d4523d
---- /dev/null
++++ b/test/pack.test.js
@@@ -0,0 +1,64 @@
++'use strict';
++/**
++ * `npm pack`, install the tarball into a fresh project (offline: the package has no runtime
++ * dependencies), then require and import every subpath from there, as a consumer would.
++ */
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const { execFileSync } = require('node:child_process');
++const { run } = require('./helpers');
++
++const ROOT = path.join(__dirname, '..');
++const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
++const pkg = require('../package.json');
++
++run([
++ ['the tarball installs and every subpath loads (CJS and ESM)', async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'openvibe-sdk-pack-'));
++ try {
++ const env = { ...process.env, npm_config_cache: path.join(tmp, '.npm-cache'), npm_config_audit: 'false', npm_config_fund: 'false', npm_config_update_notifier: 'false' };
++ const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8' });
++ const info = JSON.parse(out)[0];
++ const files = info.files.map((f) => f.path);
++ for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'esm/core.mjs', 'types/core.d.ts', 'types/contracts.d.ts']) {
++ assert.ok(files.includes(must), `tarball contains ${must}`);
++ }
++ assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
++
++ const app = path.join(tmp, 'app');
++ fs.mkdirSync(app);
++ fs.writeFileSync(path.join(app, 'package.json'), JSON.stringify({ name: 'sdk-consumer', version: '1.0.0', private: true }));
++ execFileSync(npm, ['install', '--offline', '--no-package-lock', path.join(tmp, info.filename)], { cwd: app, env, stdio: 'pipe' });
++
++ const subpaths = Object.keys(pkg.exports).filter((s) => s !== './package.json').map((s) => (s === '.' ? 'openvibe-sdk' : `openvibe-sdk/${s.slice(2)}`));
++ const script = `
++ const assert = require('node:assert/strict');
++ const specs = ${JSON.stringify(subpaths)};
++ (async () => {
++ for (const s of specs) {
++ const c = require(s);
++ const e = await import(s);
++ assert.ok(Object.keys(c).length, s);
++ assert.equal(e.default, c, s);
++ }
++ const { createClient } = require('openvibe-sdk/core');
++ const { createMockPlatform } = require('openvibe-sdk/testing');
++ const { createServiceTokenClient } = require('openvibe-sdk/auth');
++ const { createMediaClient } = require('openvibe-sdk/media');
++ const platform = createMockPlatform({ clients: { app: { secret: 's', grants: [['media.object.upload', 'openvibe.media', ['app']]] } }, mediaApps: { app: {} } });
++ const client = createClient({ fetch: platform.fetch, tokenProvider: createServiceTokenClient({ clientId: 'app', clientSecret: 's', fetch: platform.fetch }) });
++ const f = await createMediaClient(client, { app: 'app' }).upload('hi', { filename: 'a.txt' });
++ assert.match(f.key, /-a\\.txt$/);
++ assert.equal(require('openvibe-sdk/package.json').version, ${JSON.stringify(pkg.version)});
++ console.log('consumer ok: ' + specs.length + ' subpaths');
++ })().catch((err) => { console.error(err); process.exit(1); });`;
++ const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
++ assert.match(res, /consumer ok: 12 subpaths/);
++ assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
++ } finally {
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++ }],
++]);
ddiff --git a/test/pkce.test.js b/test/pkce.test.js
nnew file mode 100644
iindex 0000000..90b6b83
---- /dev/null
++++ b/test/pkce.test.js
@@@ -0,0 +1,78 @@
++'use strict';
++/** PKCE (RFC 7636 S256), authorize URL, callback parsing, and the server-side code exchange. */
++const assert = require('node:assert/strict');
++const crypto = require('node:crypto');
++const { run } = require('./helpers');
++const browser = require('../src/auth/browser');
++const { exchangeCode, refreshUserToken, verifyUserToken } = require('../src/auth');
++const { createMockPlatform } = require('../src/testing');
++
++const s256 = (v) => crypto.createHash('sha256').update(v).digest('base64url');
++
++run([
++ ['RFC 7636 appendix B vector', async () => {
++ assert.equal(await browser.pkceChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'), 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
++ }],
++
++ ['verifiers are 43..128 unreserved characters and the challenge is S256 of them', async () => {
++ for (const len of [43, 64, 128]) {
++ const { codeVerifier, codeChallenge, codeChallengeMethod } = await browser.createPkcePair(len);
++ assert.equal(codeVerifier.length, len);
++ assert.match(codeVerifier, /^[A-Za-z0-9\-._~]+$/);
++ assert.equal(codeChallenge, s256(codeVerifier));
++ assert.equal(codeChallengeMethod, 'S256');
++ }
++ assert.throws(() => browser.createCodeVerifier(42), RangeError);
++ assert.throws(() => browser.createCodeVerifier(129), RangeError);
++ const a = browser.createCodeVerifier();
++ const b = browser.createCodeVerifier();
++ assert.notEqual(a, b);
++ }],
++
++ ['authorize URL carries every parameter', async () => {
++ const { url, state, codeVerifier, codeChallenge } = await browser.startAuthorization({ clientId: 'demo', redirectUri: 'https://demo.example/cb', scope: ['profile', 'theme'] });
++ const u = new URL(url);
++ assert.equal(u.origin + u.pathname, 'https://openvibe.network/oauth/authorize');
++ assert.equal(u.searchParams.get('response_type'), 'code');
++ assert.equal(u.searchParams.get('client_id'), 'demo');
++ assert.equal(u.searchParams.get('redirect_uri'), 'https://demo.example/cb');
++ assert.equal(u.searchParams.get('scope'), 'profile theme');
++ assert.equal(u.searchParams.get('state'), state);
++ assert.equal(u.searchParams.get('code_challenge'), codeChallenge);
++ assert.equal(u.searchParams.get('code_challenge_method'), 'S256');
++ assert.equal(codeChallenge, s256(codeVerifier));
++ assert.ok(state.length >= 32);
++ const silent = new URL(browser.buildAuthorizeUrl({ network: 'http://127.0.0.1:4000/', clientId: 'x', redirectUri: 'http://a/b', prompt: 'none' }));
++ assert.equal(silent.origin, 'http://127.0.0.1:4000');
++ assert.equal(silent.searchParams.get('prompt'), 'none');
++ assert.throws(() => browser.buildAuthorizeUrl({ clientId: 'x' }), TypeError);
++ }],
++
++ ['readCallback checks state and surfaces errors', async () => {
++ assert.deepEqual(browser.readCallback('https://a/cb?code=abc&state=s1', { expectedState: 's1' }), { code: 'abc', state: 's1' });
++ assert.throws(() => browser.readCallback('https://a/cb?code=abc&state=evil', { expectedState: 's1' }), { code: 'oauth.state_mismatch' });
++ assert.throws(() => browser.readCallback('/cb?error=login_required&state=s1'), { code: 'oauth.login_required' });
++ assert.throws(() => browser.readCallback('/cb?state=s1'), { code: 'oauth.missing_code' });
++ }],
++
++ ['server callback exchanges the code with the verifier (mock Network checks PKCE)', async () => {
++ const platform = createMockPlatform({ clients: { demo: { secret: 'demo-secret' } }, users: [{ username: 'ana' }] });
++ const user = [...platform.state.users.values()][0];
++ const redirectUri = 'https://demo.example/cb';
++ const { codeVerifier, codeChallenge } = await browser.createPkcePair();
++
++ const code = platform.authorize({ clientId: 'demo', redirectUri, subjectId: user.subject_id, codeChallenge });
++ const tokens = await exchangeCode({ fetch: platform.fetch, clientId: 'demo', clientSecret: 'demo-secret', code, redirectUri, codeVerifier });
++ assert.ok(tokens.access_token && tokens.refresh_token);
++ const claims = await verifyUserToken(tokens.access_token, { jwks: platform.keys.jwks, issuer: platform.issuer, audience: 'openvibe.network' });
++ assert.equal(claims.subject_id, user.subject_id);
++
++ await assert.rejects(exchangeCode({ fetch: platform.fetch, clientId: 'demo', clientSecret: 'demo-secret', code, redirectUri, codeVerifier }), { code: 'invalid_grant' });
++ const code2 = platform.authorize({ clientId: 'demo', redirectUri, subjectId: user.subject_id, codeChallenge });
++ await assert.rejects(exchangeCode({ fetch: platform.fetch, clientId: 'demo', clientSecret: 'demo-secret', code: code2, redirectUri, codeVerifier: browser.createCodeVerifier() }), (err) => err.code === 'invalid_grant' && /PKCE/.test(err.detail));
++
++ const refreshed = await refreshUserToken({ fetch: platform.fetch, clientId: 'demo', clientSecret: 'demo-secret', refreshToken: tokens.refresh_token });
++ assert.ok(refreshed.access_token);
++ await assert.rejects(refreshUserToken({ fetch: platform.fetch, clientId: 'demo', clientSecret: 'demo-secret', refreshToken: tokens.refresh_token }), { code: 'invalid_grant' });
++ }],
++]);
ddiff --git a/test/realtime.test.js b/test/realtime.test.js
nnew file mode 100644
iindex 0000000..2bebd7c
---- /dev/null
++++ b/test/realtime.test.js
@@@ -0,0 +1,118 @@
++'use strict';
++/** Realtime SSE: resume from Last-Event-ID after a drop, dedupe, gap callback, transports. */
++const assert = require('node:assert/strict');
++const { stubServer, run, waitFor } = require('./helpers');
++const { subscribe, createRealtimeClient } = require('../src/realtime');
++const { createClient } = require('../src/core');
++const { createMockPlatform } = require('../src/testing');
++
++const frame = (seq, type = 'live.stream.started') => `id: ${seq}\ndata: ${JSON.stringify({ seq, event: { event_type: type, event_id: `e${seq}` } })}\n\n`;
++
++run([
++ ['resumes with Last-Event-ID after a drop, skips repeats, reports the gap', async () => {
++ const connections = [];
++ const srv = await stubServer((req, res) => {
++ connections.push({ lastEventId: req.headers['last-event-id'], url: req.url, auth: req.headers.authorization });
++ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
++ if (connections.length === 1) {
++ res.write('retry: 10\n: connected\n\n');
++ res.write(frame(1));
++ res.write(frame(2).slice(0, 10)); // a frame split across chunks
++ setTimeout(() => { res.write(frame(2).slice(10)); res.end(); }, 20); // then the server drops us
++ } else {
++ res.write('retry: 10\n\n');
++ res.write(frame(2)); // replayed again: must not be delivered twice
++ res.write(`event: gap\ndata: ${JSON.stringify({ reason: 'retention', from_seq: 3, to_seq: 4, latest_seq: 5 })}\n\n`);
++ res.write(frame(5));
++ }
++ });
++ const got = [];
++ const gaps = [];
++ let opens = 0;
++ const sub = subscribe(['live.stream.*', 'network.notification.*'], (event, { seq }) => got.push([seq, event.event_id]), {
++ url: srv.url, token: 'user-jwt', onGap: (g) => gaps.push(g), onOpen: () => opens++, reconnectDelayMs: 10,
++ });
++ assert.equal(sub.transport, 'fetch');
++ await waitFor(() => got.length === 3);
++ assert.deepEqual(got, [[1, 'e1'], [2, 'e2'], [5, 'e5']]);
++ assert.deepEqual(gaps, [{ reason: 'retention', from_seq: 3, to_seq: 4, latest_seq: 5 }]);
++ assert.equal(connections[0].lastEventId, undefined);
++ assert.equal(connections[1].lastEventId, '2');
++ assert.equal(connections[0].auth, 'Bearer user-jwt');
++ assert.equal(new URL(connections[0].url, 'http://x').searchParams.get('topics'), 'live.stream.*,network.notification.*');
++ assert.equal(sub.lastEventId, 5);
++ assert.equal(opens, 2);
++ sub.close();
++ await sub.done;
++ await srv.close();
++ }],
++
++ ['starts from a saved lastEventId; stops on a fatal 401', async () => {
++ const srv = await stubServer((req, res) => {
++ res.writeHead(401, { 'Content-Type': 'application/problem+json' });
++ res.end(JSON.stringify({ status: 401, code: 'token.bad_signature', detail: 'bad', type: 't', title: 'x' }));
++ });
++ const errors = [];
++ const sub = subscribe('live.*', () => {}, { url: `${srv.url}/realtime/stream`, lastEventId: 41, onError: (e) => errors.push(e), reconnectDelayMs: 10 });
++ await sub.done;
++ assert.equal(srv.requests[0].headers['last-event-id'], '41');
++ assert.ok(srv.requests[0].url.startsWith('/realtime/stream?topics=live.*'));
++ assert.equal(errors[0].code, 'token.bad_signature');
++ assert.equal(sub.closed, true);
++ assert.equal(srv.requests.length, 1, 'no reconnect loop on 401');
++ await srv.close();
++ }],
++
++ ['EventSource transport: withCredentials, last_event_id, gap events, manual reconnect when CLOSED', async () => {
++ const made = [];
++ class FakeES {
++ constructor(url, init) { this.url = url; this.init = init; this.readyState = 0; this.listeners = {}; made.push(this); }
++ addEventListener(type, fn) { this.listeners[type] = fn; }
++ close() { this.readyState = 2; this.closedByClient = true; }
++ }
++ const got = [];
++ const gaps = [];
++ const sub = subscribe(['live.stream.*'], (event, { seq }) => got.push(seq), { url: 'https://events.example', EventSource: FakeES, onGap: (g) => gaps.push(g), reconnectDelayMs: 5 });
++ await waitFor(() => made.length === 1);
++ assert.equal(sub.transport, 'eventsource');
++ const es = made[0];
++ assert.deepEqual(es.init, { withCredentials: true });
++ assert.equal(new URL(es.url).searchParams.get('last_event_id'), null);
++ es.onopen();
++ es.onmessage({ data: JSON.stringify({ seq: 7, event: { event_type: 'live.stream.started' } }), lastEventId: '7' });
++ es.onmessage({ data: JSON.stringify({ seq: 7, event: {} }), lastEventId: '7' });
++ es.listeners.gap({ data: JSON.stringify({ reason: 'replay_limit', from_seq: 8, to_seq: 9 }) });
++ assert.deepEqual(got, [7]);
++ assert.equal(gaps[0].reason, 'replay_limit');
++ es.readyState = 2;
++ es.onerror();
++ await waitFor(() => made.length === 2);
++ assert.equal(new URL(made[1].url).searchParams.get('last_event_id'), '7');
++ sub.close();
++ assert.equal(made[1].closedByClient, true);
++ }],
++
++ ['against the mock platform: the client finds Events, replays, and follows live events', async () => {
++ const platform = createMockPlatform({ realtimeRetryMs: 10 });
++ const client = createClient({ fetch: platform.fetch });
++ const pub = (type, visibility = 'public') => platform.publishEvent({ event_type: type, source: 'live', visibility, actor: { type: 'service', id: 'live' }, subject: { type: 'stream', id: '1' } });
++ pub('live.stream.started');
++ pub('live.stream.internal', 'internal');
++ pub('live.stream.updated');
++ const got = [];
++ const realtime = createRealtimeClient(client, { fetch: platform.fetch, reconnectDelayMs: 10 });
++ const sub = realtime.subscribe('live.stream.*', (event, { seq }) => got.push([seq, event.event_type]), { lastEventId: 0 });
++ await waitFor(() => got.length === 2);
++ assert.deepEqual(got, [[1, 'live.stream.started'], [3, 'live.stream.updated']], 'internal events never reach an anonymous browser');
++ pub('live.stream.ended');
++ await waitFor(() => got.length === 3);
++ platform.dropRealtime();
++ pub('live.stream.started');
++ await waitFor(() => got.length === 4);
++ assert.deepEqual(got.map((g) => g[0]), [1, 3, 4, 5]);
++ const reconnect = platform.stats.requests.filter((r) => r.url.includes('/realtime/stream')).at(-1);
++ assert.equal(reconnect.headers['last-event-id'], '4');
++ sub.close();
++ await sub.done;
++ }],
++]);
ddiff --git a/test/registry.test.js b/test/registry.test.js
nnew file mode 100644
iindex 0000000..21b901c
---- /dev/null
++++ b/test/registry.test.js
@@@ -0,0 +1,114 @@
++'use strict';
++/** Discovery (/.well-known/openvibe) caching and version negotiation; registry routes. */
++const assert = require('node:assert/strict');
++const { stubServer, send, problem, run, sleep } = require('./helpers');
++const { createClient } = require('../src/core');
++const { createRegistryClient } = require('../src/registry');
++
++async function networkStub({ version = '0.6.0', mediaOrigin } = {}) {
++ let v = version;
++ const srv = await stubServer(async (req, res) => {
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname === '/.well-known/openvibe') {
++ await sleep(10);
++ return send(res, 200, {
++ name: 'OpenVibe', issuer: 'https://openvibe.network', token_endpoint: 'https://openvibe.network/oauth/token', jwks_uri: 'https://openvibe.network/api/.well-known/jwks',
++ registry: 'https://openvibe.network/api/v1/registry', contracts: { package: 'openvibe-contracts', version: v },
++ services: [{ id: 'network', status: 'stable', origin: 'https://openvibe.network' }, { id: 'media', status: 'beta', origin: mediaOrigin || 'https://openvibe.media' }, { id: 'sdk', status: 'placeholder', origin: null }],
++ });
++ }
++ if (u.pathname === '/api/v1/registry/services') return send(res, 200, { services: [{ id: 'media', status: u.searchParams.get('status') || 'beta' }], contracts_version: v });
++ if (u.pathname === '/api/v1/registry/services/media') return send(res, 200, { id: 'media', capability_details: [] });
++ if (u.pathname.startsWith('/api/v1/registry/services/')) return problem(res, 404, 'registry.unknown_service', 'no service');
++ if (u.pathname === '/api/v1/registry/capabilities') return send(res, 200, { capabilities: [{ id: 'media.object.upload', owner: u.searchParams.get('owner') }] });
++ if (u.pathname === '/api/v1/registry/namespaces') return send(res, 200, { namespaces: [{ namespace: 'chat.preferences' }] });
++ if (u.pathname === '/api/v1/registry/contracts') return send(res, 200, { version: v, contracts: [] });
++ if (u.pathname === '/api/v1/registry/domains/openvibe.media') return send(res, 200, { domain: 'openvibe.media', service: { id: 'media' } });
++ if (u.pathname.startsWith('/api/v1/registry/domains/')) return problem(res, 404, 'registry.unknown_domain', 'nope');
++ if (u.pathname === '/api/v1/registry/topics') return send(res, 200, { topics: [] });
++ if (u.pathname === '/api/v1/media/files') return send(res, 200, { served: 'media' });
++ return send(res, 404, { error: 'Not found' });
++ });
++ return { srv, setVersion: (x) => { v = x; }, discoveries: () => srv.requests.filter((r) => r.url === '/.well-known/openvibe').length };
++}
++
++run([
++ ['discover() caches the descriptor; concurrent callers share one fetch', async () => {
++ const n = await networkStub();
++ const client = createClient({ network: n.srv.url });
++ const [a, b, c] = await Promise.all([client.discover(), client.discover(), client.discover()]);
++ assert.equal(a, b);
++ assert.equal(b, c);
++ assert.equal(await client.discover(), a);
++ assert.equal(n.discoveries(), 1);
++ assert.equal(a.contractsVersion, '0.6.0');
++ assert.equal(a.compatible, true);
++ assert.equal(a.origins.media, 'https://openvibe.media');
++ assert.equal(a.tokenEndpoint, 'https://openvibe.network/oauth/token');
++ assert.equal(client.discovery(), a);
++ await client.discover({ force: true });
++ assert.equal(n.discoveries(), 2);
++ assert.equal(n.srv.requests[0].headers.authorization, undefined, 'discovery is anonymous');
++ await n.srv.close();
++ }],
++
++ ['the cache expires after discoveryTtlMs', async () => {
++ const n = await networkStub();
++ const client = createClient({ network: n.srv.url, discoveryTtlMs: 40 });
++ await client.discover();
++ await client.discover();
++ await sleep(60);
++ await client.discover();
++ assert.equal(n.discoveries(), 2);
++ await n.srv.close();
++ }],
++
++ ['service origins come from discovery unless baseUrls say otherwise', async () => {
++ const n = await networkStub();
++ const self = await networkStub({ mediaOrigin: 'placeholder' });
++ const client = createClient({ network: n.srv.url });
++ assert.equal(await client.origin('media'), 'https://openvibe.media');
++ const pinned = createClient({ network: n.srv.url, baseUrls: { media: self.srv.url } });
++ assert.deepEqual(await pinned.json({ service: 'media', path: '/api/v1/media/files' }), { served: 'media' });
++ assert.equal(n.discoveries(), 1, 'the pinned client never needed discovery');
++ await assert.rejects(client.origin('sdk'), { code: 'sdk.unknown_service' });
++ assert.equal(await client.supports('media'), true);
++ assert.equal(await client.supports('sdk'), false);
++ assert.equal(await client.supports('ghost'), false);
++ await n.srv.close();
++ await self.srv.close();
++ }],
++
++ ['version negotiation: warn by default, throw when strict', async () => {
++ const n = await networkStub({ version: '1.2.0' });
++ const warnings = [];
++ const client = createClient({ network: n.srv.url, onWarning: (m) => warnings.push(m) });
++ const d = await client.discover();
++ assert.equal(d.compatible, false);
++ await client.discover({ force: true });
++ assert.equal(warnings.length, 1, 'warned once');
++ assert.match(warnings[0], /1\.2\.0/);
++ const strict = createClient({ network: n.srv.url, strictContracts: true });
++ await assert.rejects(strict.discover(), { code: 'sdk.incompatible_contracts' });
++ const wide = createClient({ network: n.srv.url, strictContracts: true, contractsRange: '^1.0.0' });
++ assert.equal((await wide.discover()).compatible, true);
++ await n.srv.close();
++ }],
++
++ ['registry methods hit /api/v1/registry and map 404 to null', async () => {
++ const n = await networkStub();
++ const registry = createRegistryClient(createClient({ network: n.srv.url }));
++ assert.deepEqual(await registry.services({ status: 'alpha' }), [{ id: 'media', status: 'alpha' }]);
++ assert.equal((await registry.service('media')).id, 'media');
++ assert.equal(await registry.service('ghost'), null);
++ assert.deepEqual(await registry.capabilities({ owner: 'media' }), [{ id: 'media.object.upload', owner: 'media' }]);
++ assert.deepEqual(await registry.namespaces(), [{ namespace: 'chat.preferences' }]);
++ assert.equal((await registry.contracts()).version, '0.6.0');
++ assert.equal((await registry.domain('OpenVibe.Media')).service.id, 'media');
++ assert.equal(await registry.domain('nowhere.example'), null);
++ assert.deepEqual(await registry.topics(), []);
++ assert.equal((await registry.descriptor()).contractsVersion, '0.6.0');
++ assert.ok(n.srv.requests.every((r) => !r.headers.authorization));
++ await n.srv.close();
++ }],
++]);
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..5dbf53e
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,56 @@
++#!/usr/bin/env node
++/**
++ * Runs every test in test/ (the files named *.test.js), each in its own process, and fails if
++ * any of them fails. They use local stub servers and the in-process mock platform only; none
++ * needs the network or a running service.
++ *
++ * npm test # everything
++ * npm test -- core pack # only files whose name contains one of the words
++ */
++'use strict';
++const { spawn } = require('child_process');
++const fs = require('fs');
++const path = require('path');
++
++const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
++const files = fs.readdirSync(__dirname)
++ .filter((f) => f.endsWith('.test.js'))
++ .filter((f) => !filters.length || filters.some((w) => f.includes(w)))
++ .sort();
++const TIMEOUT_MS = 120000;
++
++function runOne(file) {
++ return new Promise((resolve) => {
++ const started = Date.now();
++ const child = spawn(process.execPath, [path.join(__dirname, file)], {
++ cwd: path.join(__dirname, '..'),
++ 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({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });
++ });
++ });
++}
++
++(async () => {
++ if (!files.length) { console.error('no test files matched'); process.exit(1); }
++ const results = [];
++ for (const f of files) {
++ const r = await runOne(f);
++ results.push(r);
++ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(32)} ${String(r.ms).padStart(6)}ms`);
++ }
++ const failed = results.filter((r) => !r.ok);
++ for (const r of failed) {
++ console.log(`\n── ${r.file} (exit ${r.code}) ──`);
++ console.log(r.output.split('\n').slice(-60).join('\n'));
++ }
++ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);
++ process.exit(failed.length ? 1 : 0);
++})();
ddiff --git a/test/testing.test.js b/test/testing.test.js
nnew file mode 100644
iindex 0000000..28c0ffd
---- /dev/null
++++ b/test/testing.test.js
@@@ -0,0 +1,59 @@
++'use strict';
++/**
++ * Acceptance (README): an external app authenticates, discovers Media from the registry and
++ * uploads an object with only the SDK and a capability grant; plus user modules against the mock.
++ */
++const assert = require('node:assert/strict');
++const { run } = require('./helpers');
++const sdk = require('..');
++const { createMockPlatform } = require('../src/testing');
++
++run([
++ ['client credentials -> discovery -> Media upload with media.object.upload', async () => {
++ const platform = createMockPlatform({
++ clients: { 'demo-app': { secret: 'demo-secret', grants: [{ capability: 'media.object.upload', audience: 'openvibe.media', namespaces: ['demo'] }] } },
++ mediaApps: { demo: {} },
++ });
++ const tokens = sdk.auth.createServiceTokenClient({ clientId: 'demo-app', clientSecret: 'demo-secret', fetch: platform.fetch });
++ const client = sdk.createClient({ fetch: platform.fetch, tokenProvider: tokens, strictContracts: true });
++
++ const d = await client.discover();
++ assert.equal(d.origins.media, 'https://openvibe.media');
++ assert.equal(await client.supports('media'), true);
++ const media = sdk.media.createMediaClient(client, { app: 'demo' });
++ const file = await media.upload(Buffer.from('hello platform'), { filename: 'hello.txt', contentType: 'text/plain' });
++ assert.match(file.key, /^[0-9a-f]{12}-hello\.txt$/);
++ assert.equal(file.public_url, `https://openvibe.media/f/${file.key}`);
++ assert.equal(platform.state.files.size, 1);
++ assert.equal(platform.stats.tokenRequests, 1);
++
++ const again = await media.upload(Buffer.from('hello platform'), { filename: 'hello.txt' });
++ assert.equal(again.deduplicated, true);
++ assert.equal(platform.stats.tokenRequests, 1, 'token reused');
++
++ const other = sdk.media.createMediaClient(client, { app: 'someone-else' });
++ await assert.rejects(other.upload('x'), (err) => err.status === 404 || err.code === 'capability.namespace_denied');
++ await assert.rejects(media.files.list(), { status: 401 }, 'a service token only uploads; listing needs the app key');
++ }],
++
++ ['user modules with a Network user token', async () => {
++ const platform = createMockPlatform({ users: [{ username: 'ana' }] });
++ const ana = [...platform.state.users.values()][0];
++ const client = sdk.createClient({ fetch: platform.fetch, token: platform.signUserToken(ana) });
++ const modules = sdk.modules.createModulesClient(client);
++ assert.equal(await modules.get('demo.prefs'), null);
++ await modules.update('demo.prefs', (d) => ({ ...d, theme: 'blue', secret: 1 }));
++ const rec = await modules.update('demo.prefs', (d) => ({ ...d, theme: 'violet' }));
++ assert.equal(rec.revision, 2);
++ assert.deepEqual((await modules.publicGet('demo.prefs', ana.subject_id)).data, { theme: 'violet' });
++ assert.equal((await modules.list()).modules.length, 1);
++ }],
++
++ ['the registry client works against the mock', async () => {
++ const platform = createMockPlatform({ capabilities: [{ id: 'media.object.upload', owner: 'media', version: '1.0.0', status: 'active', visibility: 'first-party', permissions: [], resourceConstraints: ['namespace'], quotaClass: 'media', events: [] }] });
++ const registry = sdk.registry.createRegistryClient(sdk.createClient({ fetch: platform.fetch }));
++ assert.equal((await registry.domain('openvibe.media')).service.id, 'media');
++ assert.equal((await registry.capabilities({ owner: 'media' }))[0].id, 'media.object.upload');
++ assert.equal((await registry.namespaces())[0].namespace, 'demo.prefs');
++ }],
++]);
ddiff --git a/test/types.test.js b/test/types.test.js
nnew file mode 100644
iindex 0000000..4e8d695
---- /dev/null
++++ b/test/types.test.js
@@@ -0,0 +1,62 @@
++'use strict';
++/**
++ * Type declarations stay honest: every runtime export of a subpath is declared in its .d.ts, and
++ * the contract types copied from openvibe-contracts match a Contracts checkout when one is next
++ * to this repository (skipped otherwise).
++ */
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const path = require('node:path');
++const { run } = require('./helpers');
++
++const ROOT = path.join(__dirname, '..');
++const pkg = require('../package.json');
++
++function declared(dtsFile, seen = new Set()) {
++ if (seen.has(dtsFile)) return new Set();
++ seen.add(dtsFile);
++ const src = fs.readFileSync(dtsFile, 'utf8');
++ const names = new Set();
++ const re = /export\s+(?:declare\s+)?(?:function|const|class|interface|type|let)\s+([A-Za-z_$][\w$]*)/g;
++ let m;
++ while ((m = re.exec(src))) names.add(m[1]);
++ const ns = /export\s+\*\s+as\s+(\w+)\s+from/g;
++ while ((m = ns.exec(src))) names.add(m[1]);
++ const star = /export\s+\*\s+from\s+'(\.\/[^']+)'/g;
++ while ((m = star.exec(src))) for (const n of declared(path.join(path.dirname(dtsFile), `${m[1]}.d.ts`), seen)) names.add(n);
++ return names;
++}
++
++run([
++ ['every runtime export has a declaration', async () => {
++ const missing = [];
++ for (const [sub, entry] of Object.entries(pkg.exports)) {
++ if (sub === './package.json') continue;
++ const names = declared(path.join(ROOT, entry.types));
++ const runtime = Object.keys(require(path.join(ROOT, entry.default)));
++ for (const n of runtime) if (!names.has(n)) missing.push(`${sub}: ${n}`);
++ const browser = entry.browser ? Object.keys(require(path.join(ROOT, entry.browser))) : [];
++ for (const n of browser) if (!names.has(n)) missing.push(`${sub} (browser): ${n}`);
++ }
++ assert.deepEqual(missing, []);
++ }],
++
++ ['copied contract types match openvibe-contracts', async () => {
++ const candidates = [
++ path.join(ROOT, 'node_modules/openvibe-contracts/generated/typescript/index.d.ts'),
++ path.join(ROOT, '../OpenVibe.Contracts/generated/typescript/index.d.ts'),
++ ];
++ const source = candidates.find((p) => fs.existsSync(p));
++ if (!source) { console.log(' skipped: no openvibe-contracts checkout next to this repository'); return; }
++ const theirs = fs.readFileSync(source, 'utf8');
++ const ours = fs.readFileSync(path.join(ROOT, 'types/contracts.d.ts'), 'utf8');
++ const blocks = ours.split(/\n(?=\/\*\* [a-z]+\.[a-z-]+@)/).slice(1).map((b) => b.trim());
++ assert.ok(blocks.length >= 9);
++ for (const b of blocks) {
++ const id = b.match(/^\/\*\* ([a-z]+\.[a-z-]+)@/)[1];
++ const at = theirs.indexOf(`/** ${id}@`);
++ assert.ok(at >= 0, `${id} exists in Contracts`);
++ assert.ok(theirs.includes(b), `${id} is identical to Contracts (re-copy types/contracts.d.ts after a Contracts release)`);
++ }
++ }],
++]);
ddiff --git a/types/auth-browser.d.ts b/types/auth-browser.d.ts
nnew file mode 100644
iindex 0000000..d04efc1
---- /dev/null
++++ b/types/auth-browser.d.ts
@@@ -0,0 +1,21 @@
++export declare function createCodeVerifier(length?: number): string;
++export declare function pkceChallenge(verifier: string): Promise<string>;
++export declare function createPkcePair(length?: number): Promise<{ codeVerifier: string; codeChallenge: string; codeChallengeMethod: 'S256' }>;
++export declare function createState(): string;
++
++export interface AuthorizeOptions {
++ network?: string;
++ authorizeUrl?: string;
++ clientId: string;
++ redirectUri: string;
++ scope?: string | string[];
++ state?: string;
++ codeChallenge?: string;
++ codeChallengeMethod?: 'S256';
++ /** 'none' = silent SSO; the callback carries error=login_required when there is no session. */
++ prompt?: 'none';
++}
++export declare function buildAuthorizeUrl(opts: AuthorizeOptions): string;
++export declare function startAuthorization(opts: Omit<AuthorizeOptions, 'codeChallenge' | 'codeChallengeMethod'> & { verifierLength?: number }): Promise<{ url: string; state: string; codeVerifier: string; codeChallenge: string }>;
++export declare function readCallback(location: string | URL, opts?: { expectedState?: string }): { code: string; state: string };
++export declare function base64url(bytes: Uint8Array): string;
ddiff --git a/types/auth.d.ts b/types/auth.d.ts
nnew file mode 100644
iindex 0000000..f33bb3a
---- /dev/null
++++ b/types/auth.d.ts
@@@ -0,0 +1,65 @@
++import type { TokenContext, TokenProvider, FetchLike } from './core';
++export * from './auth-browser';
++
++export interface ServiceTokenClientOptions {
++ network?: string;
++ tokenUrl?: string;
++ clientId: string;
++ clientSecret: string;
++ /** Default audience; each call from createClient passes the audience of the service it calls. */
++ audience?: string;
++ /** Capability ids to narrow the token to; a map narrows per audience. */
++ scope?: string | string[] | Record<string, string | string[]>;
++ fetch?: FetchLike;
++ timeoutMs?: number;
++ refreshSkewMs?: number;
++ now?: () => number;
++}
++export interface ServiceTokenClient extends TokenProvider {
++ getToken(ctx?: TokenContext): Promise<string>;
++ authHeaders(ctx?: TokenContext): Promise<{ Authorization: string }>;
++ invalidate(ctx?: TokenContext): void;
++ readonly tokenUrl: string;
++}
++export declare function createServiceTokenClient(opts: ServiceTokenClientOptions): ServiceTokenClient;
++
++export interface UserTokenClaims {
++ sub: number | string;
++ id?: number | string;
++ /** Canonical subject (usr_…); absent only on very old tokens. */
++ subject_id?: string;
++ username?: string;
++ display_name?: string;
++ role?: string;
++ avatar_url?: string | null;
++ iss?: string;
++ aud?: string | string[];
++ iat?: number;
++ exp: number;
++ [claim: string]: unknown;
++}
++export interface VerifyUserTokenOptions {
++ /** JWKS document, or its URL (fetched and cached 6 h, refetched on an unknown kid). */
++ jwks?: { keys?: object[]; public_key?: string } | string;
++ /** PEM string or a crypto KeyObject instead of a JWKS. */
++ publicKey?: string | object;
++ issuer?: string;
++ audience?: string | string[];
++ clockSkewSec?: number;
++ now?: number;
++ fetch?: FetchLike;
++ allowServiceTokens?: boolean;
++}
++export declare function verifyUserToken(token: string, opts: VerifyUserTokenOptions): Promise<UserTokenClaims>;
++
++export interface UserTokenResponse {
++ access_token: string;
++ refresh_token?: string;
++ token_type: 'Bearer';
++ expires_in: number;
++ scope?: string;
++ user?: Record<string, unknown>;
++ preferences?: Record<string, unknown>;
++}
++export declare function exchangeCode(opts: { code: string; redirectUri: string; codeVerifier?: string; clientId: string; clientSecret: string; network?: string; tokenUrl?: string; fetch?: FetchLike; timeoutMs?: number }): Promise<UserTokenResponse>;
++export declare function refreshUserToken(opts: { refreshToken: string; clientId: string; clientSecret: string; network?: string; tokenUrl?: string; fetch?: FetchLike; timeoutMs?: number }): Promise<UserTokenResponse>;
ddiff --git a/types/community.d.ts b/types/community.d.ts
nnew file mode 100644
iindex 0000000..fe7147e
---- /dev/null
++++ b/types/community.d.ts
@@@ -0,0 +1,42 @@
++import type { OpenVibeClient, EntityRef } from './core';
++
++export interface ActingOptions {
++ /** usr_… or gst_… (services only). */
++ actingSubject?: string;
++ origin?: 'ai' | 'user';
++ sourceRef?: EntityRef | string;
++ staff?: boolean;
++}
++export type CallOptions = ActingOptions & { signal?: AbortSignal };
++
++export interface Paste { id: number; slug: string; title?: string; content?: string; language?: string; type?: string; visibility?: string; [field: string]: unknown; }
++export interface PasteListQuery { limit?: number; offset?: number; type?: string; search?: string; sort?: string; origin?: string; username?: string; include_unlisted?: boolean; }
++export interface CreateTextPaste { title?: string; content: string; language?: string; visibility?: 'public' | 'unlisted' | 'private'; burn_after_read?: boolean; is_nsfw?: boolean; [field: string]: unknown; }
++export interface CreateScreenshotPaste { screenshot: Blob | ArrayBuffer | ArrayBufferView; filename?: string; contentType?: string; title?: string; visibility?: 'public' | 'unlisted' | 'private'; [field: string]: unknown; }
++
++export interface PastesApi {
++ list(query?: PasteListQuery, opts?: CallOptions): Promise<{ pastes: Paste[]; total: number; limit: number; offset: number; hasMore?: boolean }>;
++ iterate(query?: PasteListQuery, opts?: CallOptions): AsyncGenerator<Paste, void, unknown>;
++ get(slug: string, opts?: CallOptions & { noView?: boolean }): Promise<{ paste: Paste; liked?: boolean } | null>;
++ create(input: CreateTextPaste | CreateScreenshotPaste, opts?: CallOptions): Promise<{ id: number; slug: string; url: string; [field: string]: unknown }>;
++ update(slug: string, patch: Partial<CreateTextPaste>, opts?: CallOptions): Promise<{ paste: Paste }>;
++ delete(slug: string, opts?: CallOptions): Promise<unknown>;
++ fork(slug: string, opts?: CallOptions): Promise<{ id: number; slug: string; url: string; [field: string]: unknown }>;
++ like(slug: string, opts?: CallOptions): Promise<{ liked: boolean; likes?: number; [field: string]: unknown }>;
++ copy(slug: string, opts?: CallOptions): Promise<{ copies: number }>;
++ versions(slug: string, opts?: CallOptions): Promise<{ revision: number; versions: Array<Record<string, unknown>> }>;
++ byUser(username: string, query?: { limit?: number; offset?: number }, opts?: CallOptions): Promise<{ pastes: Paste[]; total: number; username: string }>;
++ config(opts?: CallOptions): Promise<Record<string, unknown>>;
++ comments: {
++ list(slug: string, query?: { limit?: number; offset?: number }, opts?: CallOptions): Promise<{ comments: Array<Record<string, unknown>>; total: number }>;
++ create(slug: string, input: { content: string; parent_id?: number | null; [field: string]: unknown }, opts?: CallOptions): Promise<{ comment: Record<string, unknown> }>;
++ delete(slug: string, commentId: string | number, opts?: CallOptions): Promise<unknown>;
++ };
++}
++export interface CommunityClient {
++ pastes: PastesApi;
++ as(who: string | ActingOptions): CommunityClient;
++ headers(): Record<string, string>;
++}
++export declare function createCommunityClient(client: OpenVibeClient, defaults?: ActingOptions & { baseUrl?: string }): CommunityClient;
++export declare function actingHeaders(opts?: ActingOptions): Record<string, string>;
ddiff --git a/types/contracts.d.ts b/types/contracts.d.ts
nnew file mode 100644
iindex 0000000..3390d3d
---- /dev/null
++++ b/types/contracts.d.ts
@@@ -0,0 +1,224 @@
++/* Copied verbatim from openvibe-contracts v0.6.0 generated/typescript/index.d.ts (the SDK does not
++ * require the package at runtime). test/types.test.js checks these against a Contracts checkout. */
++/* eslint-disable */
++
++/** [email protected] (owner: network) */
++/**
++ * The one cross-platform reference to an actor. Issued by OpenVibe.Network. Never a service-local integer id.
++ */
++export type SubjectRef =
++ | {
++ type: "user";
++ id: string;
++ }
++ | {
++ type: "guest";
++ id: string;
++ }
++ | {
++ type: "app";
++ id: string;
++ }
++ | {
++ type: "mod";
++ id: string;
++ }
++ | {
++ type: "service" | "system";
++ id: string;
++ };
++
++/** [email protected] (owner: network) */
++/**
++ * Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service or app principal. Replaces X-Internal-Key.
++ */
++export interface ServiceTokenClaims {
++ /**
++ * Issuer; https://openvibe.network in production. Receivers pass the issuer they expect to verifyServiceToken().
++ */
++ iss: string;
++ sub: string;
++ actor_type: "service" | "app" | "mod";
++ /**
++ * @minItems 1
++ */
++ aud: [string, ...string[]];
++ /**
++ * Granted capability ids; a trailing .* grants a family.
++ */
++ cap: string[];
++ /**
++ * Namespace constraints, e.g. live.* or mod.example.*
++ */
++ ns?: string[];
++ iat: number;
++ exp: number;
++ jti: string;
++ [k: string]: unknown | undefined;
++}
++
++/** [email protected] (owner: contracts) */
++/**
++ * Typed reference to another service's entity. Store this instead of a foreign key; resolve a display projection from the owner.
++ */
++export interface EntityRef {
++ service: string;
++ type: string;
++ id: string;
++ revision?: number;
++ /**
++ * Cached display label. Never authoritative.
++ */
++ label?: string;
++}
++
++/** [email protected] (owner: contracts) */
++/**
++ * RFC 9457 problem details (application/problem+json) with a stable OpenVibe error code and trace identifiers. The legacy 'error' string is allowed during migration so existing clients keep working.
++ */
++export interface Problem {
++ type: string;
++ title: string;
++ status: number;
++ detail?: string;
++ instance?: string;
++ code: string;
++ request_id?: string;
++ trace_id?: string;
++ errors?: {
++ path?: string;
++ message: string;
++ }[];
++ /**
++ * Deprecated compatibility field: same text as title/detail for clients that read {error}.
++ */
++ error?: string;
++ [k: string]: unknown | undefined;
++}
++
++/** [email protected] (owner: network) */
++/**
++ * What a service is, where it lives and what it offers. The ecosystem registry is built from these, not from hard-coded route maps.
++ */
++export interface ServiceManifest {
++ id: string;
++ name?: string;
++ version: string;
++ status: "placeholder" | "alpha" | "beta" | "stable" | "degraded" | "retired";
++ repository: string;
++ domains: string[];
++ publicOrigin?: string;
++ health?: string;
++ ready?: string;
++ capabilities: string[];
++ eventsProduced: string[];
++ eventsConsumed: string[];
++ namespacesOwned: string[];
++ contractRanges?: {
++ [k: string]: string | undefined;
++ };
++ notes?: string;
++}
++
++/** [email protected] (owner: network) */
++/**
++ * An action a principal may invoke. Authorization is checked at the owner's boundary against the grant, never inferred from the caller's route.
++ */
++export interface Capability {
++ id: string;
++ version: string;
++ owner: string;
++ status: "planned" | "active" | "deprecated" | "retired";
++ visibility: "public" | "first-party" | "internal";
++ description?: string;
++ inputSchema?: string;
++ outputSchema?: string;
++ permissions: string[];
++ resourceConstraints: ("namespace" | "owner" | "room_member" | "space_member" | "project" | "none")[];
++ quotaClass: string;
++ events: string[];
++ /**
++ * Current route(s) that perform this action today, e.g. 'POST /api/v1/:app/files'.
++ */
++ implementedBy?: string[];
++}
++
++/** [email protected] (owner: events) */
++/**
++ * Durable platform event (OpenVibe.Events, Wave 3). Producers write it to an outbox in the same transaction as the domain change.
++ */
++export interface EventEnvelope {
++ event_id: string;
++ trace_id?: string;
++ event_type: string;
++ version: number;
++ source: string;
++ actor: SubjectRef;
++ timestamp: string;
++ priority?: "critical" | "important" | "low";
++ visibility?: "public" | "subject" | "internal";
++ subject: {
++ type: string;
++ id: string;
++ revision?: number;
++ };
++ payload: {};
++}
++
++/** [email protected] (owner: network) */
++/**
++ * Policy for one user-module namespace: portable per-subject summaries and preferences stored by OpenVibe.Network. Never domain truth, money or authoritative game inventory (roadmap 4.3-4.5).
++ */
++export interface ModuleNamespace {
++ namespace: string;
++ /**
++ * Service that owns the namespace and may write it with network.modules.write.
++ */
++ owner: string;
++ /**
++ * Schema version stored with every record; a change needs a migration note.
++ */
++ version: number;
++ description?: string;
++ /**
++ * JSON Schema (2020-12) every stored value must satisfy.
++ */
++ schema: {};
++ /**
++ * owner = the owning service with a token; user = the subject themselves.
++ *
++ * @minItems 1
++ */
++ writers: ["owner" | "user", ...("owner" | "user")[]];
++ /**
++ * Top-level fields anyone may read. Everything else is readable only by the subject and granted services.
++ */
++ publicFields: string[];
++ quotaBytes: number;
++ /**
++ * What happens to records when the owning service or mod is retired.
++ */
++ onOwnerRemoved: "retain-readonly" | "delete-after-retention";
++ retentionDays?: number;
++ /**
++ * How records of the previous version are upgraded.
++ */
++ migration?: string;
++}
++
++/** [email protected] (owner: network) */
++/**
++ * One subject's value in one namespace, as returned by Network. revision increases by one on every write; a write names the revision it read (If-Match) and fails with 412 if it moved.
++ */
++export interface ModuleRecord {
++ subject: SubjectRef;
++ namespace: string;
++ version: number;
++ revision: number;
++ data: {};
++ updated_at: string;
++ /**
++ * user:<subject> or svc:<service>
++ */
++ updated_by?: string;
++}
ddiff --git a/types/core.d.ts b/types/core.d.ts
nnew file mode 100644
iindex 0000000..59791b8
---- /dev/null
++++ b/types/core.d.ts
@@@ -0,0 +1,159 @@
++import type { Problem } from './contracts';
++export type { SubjectRef, ServiceTokenClaims, EntityRef, Problem, ServiceManifest, Capability, EventEnvelope, ModuleNamespace, ModuleRecord } from './contracts';
++
++export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
++
++export interface TokenContext { service?: string; audience?: string; scope?: string | string[]; }
++
++export interface TokenProvider {
++ getToken(ctx?: TokenContext): Promise<string | null | undefined> | string | null | undefined;
++ invalidate?(ctx?: TokenContext): void | Promise<void>;
++}
++
++export interface ClientOptions {
++ /** Network origin: discovery (/.well-known/openvibe), registry, identity, modules. Default https://openvibe.network */
++ network?: string;
++ discoveryUrl?: string;
++ /** Explicit origins per service id; win over discovery (e.g. host-internal http://127.0.0.1:4100 for media). */
++ baseUrls?: Record<string, string>;
++ /** Token audience per service; default `openvibe.<service>`. */
++ audiences?: Record<string, string>;
++ fetch?: FetchLike;
++ /** Per attempt, default 10000. */
++ timeoutMs?: number;
++ /** Whole call including retries, default 30000. */
++ deadlineMs?: number;
++ /** Default 2. */
++ retries?: number;
++ retryDelayMs?: number;
++ maxRetryDelayMs?: number;
++ /** One of: a fixed Bearer token (user JWT, app key), a function, or a provider (createServiceTokenClient). */
++ token?: string;
++ getToken?: (ctx: TokenContext) => Promise<string | null | undefined> | string | null | undefined;
++ tokenProvider?: TokenProvider;
++ invalidateToken?: (ctx: TokenContext) => void | Promise<void>;
++ headers?: Record<string, string>;
++ /** Browser: 'include' sends the Network session cookie. */
++ credentials?: RequestCredentials;
++ discoveryTtlMs?: number;
++ autoDiscover?: boolean;
++ /** Contracts releases accepted from discovery; default CONTRACTS_RANGE. */
++ contractsRange?: string;
++ /** Throw sdk.incompatible_contracts instead of warning. */
++ strictContracts?: boolean;
++ /** Parent trace for calls without their own: a traceparent string or a getter (e.g. from AsyncLocalStorage). */
++ traceparent?: string | (() => string | null | undefined) | null;
++ onWarning?: (message: string) => void;
++}
++
++export interface RequestOptions {
++ service?: string;
++ baseUrl?: string;
++ url?: string;
++ path?: string;
++ method?: string;
++ query?: Record<string, string | number | boolean | string[] | null | undefined>;
++ json?: unknown;
++ form?: FormData;
++ urlencoded?: Record<string, string | number | boolean | null | undefined> | URLSearchParams;
++ body?: BodyInit;
++ headers?: Record<string, string | undefined>;
++ /** Per-call token; null sends none. */
++ token?: string | null;
++ /** false: never send Authorization. */
++ auth?: boolean;
++ audience?: string;
++ /** A key makes a mutation retryable; false = never generate one (and never retry the mutation). */
++ idempotencyKey?: string | false;
++ /** The server dedupes this mutation by itself: retry without a key. */
++ idempotent?: boolean;
++ retries?: number;
++ timeoutMs?: number;
++ deadlineMs?: number;
++ signal?: AbortSignal;
++ traceparent?: string;
++ requestId?: string;
++ credentials?: RequestCredentials;
++ responseType?: 'json' | 'text' | 'arrayBuffer' | 'response';
++}
++
++export interface ClientResponse<T = any> {
++ status: number;
++ headers: Headers;
++ data: T;
++ requestId: string;
++ traceId: string;
++ traceparent: string;
++ attempts: number;
++}
++
++export interface Discovery {
++ issuer: string | null;
++ tokenEndpoint: string | null;
++ jwksUri: string | null;
++ registry: string | null;
++ services: Array<{ id: string; status: string; origin: string | null }>;
++ origins: Record<string, string>;
++ contractsVersion: string | null;
++ contractsRange: string;
++ compatible: boolean;
++ fetchedAt: string;
++ raw: any;
++}
++
++export interface OpenVibeClient {
++ request<T = any>(opts: RequestOptions): Promise<ClientResponse<T>>;
++ json<T = any>(opts: RequestOptions): Promise<T>;
++ discover(opts?: { force?: boolean }): Promise<Discovery>;
++ discovery(): Discovery | null;
++ origin(service: string): Promise<string>;
++ supports(service: string): Promise<boolean>;
++ url(opts: Pick<RequestOptions, 'url' | 'service' | 'baseUrl' | 'path' | 'query'>): Promise<string>;
++ audienceOf(service: string): string;
++ traceparent(): string | null | undefined;
++ withContext(ctx: { traceparent?: string; requestId?: string; headers?: Record<string, string> }): OpenVibeClient;
++ fromRequest(req: { headers: any } | Headers | Record<string, any>): OpenVibeClient;
++ readonly options: Readonly<Required<Pick<ClientOptions, 'network' | 'timeoutMs' | 'deadlineMs' | 'retries'>>> & Readonly<ClientOptions>;
++}
++
++export declare function createClient(options?: ClientOptions): OpenVibeClient;
++
++export declare class OpenVibeError extends Error {
++ name: 'OpenVibeError';
++ /** Stable code: problem `code`, OAuth `error`, `http.<status>`, or `sdk.*`. */
++ code: string;
++ /** HTTP status; 0 for failures without a response. */
++ status: number;
++ title?: string;
++ detail?: string;
++ type?: string;
++ requestId: string | null;
++ traceId: string | null;
++ errors?: Array<{ path?: string; message: string }>;
++ problem: Problem | null;
++ retryable: boolean;
++ method?: string;
++ url?: string;
++ constructor(init?: Partial<Omit<OpenVibeError, 'name'>> & { message?: string; cause?: unknown });
++ static fromResponse(init: { status: number; body: unknown; requestId?: string; traceId?: string; method?: string; url?: string; retryable?: boolean }): OpenVibeError;
++ toJSON(): Record<string, unknown>;
++}
++export declare function isOpenVibeError(err: unknown): err is OpenVibeError;
++
++export interface Page<T, C = unknown> { items: T[]; next?: C | null; }
++export declare function paginate<T, C = unknown>(fetchPage: (cursor: C) => Promise<Page<T, C>>, opts?: { cursor?: C; maxPages?: number; maxItems?: number }): AsyncGenerator<T, void, unknown>;
++export declare function offsetPager<T>(load: (offset: number, limit: number) => Promise<{ items: T[]; total?: number }>, opts?: { limit?: number }): (offset?: number) => Promise<Page<T, number>>;
++
++export declare function parseTraceparent(value: unknown): { traceId: string; parentId: string; flags: string } | null;
++export declare function startSpan(parent?: string | null): { traceId: string; spanId: string; parentId: string | null; traceparent: string };
++export declare function contextFromHeaders(headers: any): { traceparent?: string; requestId?: string };
++
++export declare function ulid(now?: number): string;
++export declare function newEventId(now?: number): string;
++export declare function newIdempotencyKey(): string;
++export declare function isActingSubjectId(id: unknown): boolean;
++export declare function satisfiesRange(version: string, range: string): boolean;
++export declare function compareVersions(a: string, b: string): -1 | 0 | 1;
++export declare const CONTRACTS_RANGE: string;
++export declare const DEFAULT_NETWORK: string;
++export declare const SDK_VERSION: string;
ddiff --git a/types/events.d.ts b/types/events.d.ts
nnew file mode 100644
iindex 0000000..8b8a149
---- /dev/null
++++ b/types/events.d.ts
@@@ -0,0 +1,47 @@
++import type { OpenVibeClient, EventEnvelope } from './core';
++
++/** What a producer passes: event_id, timestamp, source, version, payload and trace_id are filled in. */
++export type EventInput = Omit<EventEnvelope, 'event_id' | 'timestamp' | 'source' | 'version' | 'payload'> & Partial<Pick<EventEnvelope, 'event_id' | 'timestamp' | 'source' | 'version' | 'payload'>>;
++export interface PublishResult { event_id: string; seq: number; duplicate: boolean; }
++export interface StoredEvent { seq: number; event: EventEnvelope; }
++export interface Gap { from_seq: number; to_seq: number; reason?: string; latest_seq?: number; }
++export interface EventsPage { events: StoredEvent[]; next_after_seq: number; latest_seq: number; gap?: Gap; }
++export interface Subscription {
++ id: string;
++ consumer: string;
++ topic_pattern: string;
++ endpoint: string;
++ enabled: boolean;
++ retry_policy: { max_attempts?: number; backoff_ms?: number[] } | null;
++ /** Only in the create response. */
++ secret?: string;
++ [field: string]: unknown;
++}
++export interface CreateSubscription { topicPattern: string; endpoint: string; secret?: string; retryPolicy?: { max_attempts?: number; backoff_ms?: number[] }; }
++
++export interface EventsClient {
++ prepare(envelope: EventInput, opts?: { traceId?: string; now?: number }): EventEnvelope;
++ publish(envelope: EventInput, opts?: { traceparent?: string }): Promise<PublishResult>;
++ publish(envelopes: EventInput[], opts?: { traceparent?: string }): Promise<{ results: PublishResult[] }>;
++ pull(opts?: { topic?: string | string[]; afterSeq?: number; limit?: number }): Promise<EventsPage>;
++ iterate(opts?: { topic?: string | string[]; afterSeq?: number; limit?: number; onGap?: (gap: Gap) => void | Promise<void>; onPage?: (page: EventsPage) => void | Promise<void> }): AsyncGenerator<StoredEvent, void, unknown>;
++ get(eventId: string): Promise<StoredEvent | null>;
++ getCheckpoint(topic: string): Promise<{ consumer: string; topic: string; cursor: number; updated_at: string | null }>;
++ setCheckpoint(topic: string, cursor: number): Promise<{ consumer: string; topic: string; cursor: number }>;
++ subscriptions: {
++ create(input: CreateSubscription): Promise<Subscription>;
++ list(): Promise<Subscription[]>;
++ get(id: string): Promise<Subscription | null>;
++ disable(id: string): Promise<Subscription>;
++ enable(id: string): Promise<Subscription>;
++ };
++ subscribe(input: CreateSubscription): Promise<Subscription>;
++ deliveries(opts?: { status?: 'pending' | 'delivered' | 'failed' | 'dead'; subscriptionId?: string; afterSeq?: number; limit?: number }): Promise<{ deliveries: Array<Record<string, unknown>>; counts: Record<string, number> }>;
++ replay(opts: { subscriptionId: string; fromSeq?: number; eventIds?: string[] }): Promise<{ subscription_id: string; queued: number }>;
++}
++export declare function createEventsClient(client: OpenVibeClient, opts?: { source?: string; baseUrl?: string }): EventsClient;
++
++type RawBody = string | Uint8Array | ArrayBufferView;
++export declare function signDelivery(rawBody: RawBody, secret: string): string;
++export declare function verifyDelivery(rawBody: RawBody, signatureHeader: string | undefined | null, secret: string): boolean;
++export declare function parseDelivery(rawBody: RawBody, headers: Record<string, any> | Headers, secret: string): { event: EventEnvelope; seq: number; subscriptionId: string | null; attempt: number } | null;
ddiff --git a/types/identity.d.ts b/types/identity.d.ts
nnew file mode 100644
iindex 0000000..028f25c
---- /dev/null
++++ b/types/identity.d.ts
@@@ -0,0 +1,20 @@
++import type { OpenVibeClient, SubjectRef } from './core';
++
++export interface SubjectProjection {
++ subject: SubjectRef;
++ network_user_id?: number;
++ network_anon_id?: number;
++ username: string;
++ display_name: string;
++ avatar_url: string | null;
++ banned: boolean;
++ [field: string]: unknown;
++}
++export type ResolveInput = { subjectId: string } | { system: string; type?: string; id: string | number };
++export type ResolveBatchInput = { subjectIds: string[] } | { system: string; type?: string; ids: Array<string | number> };
++
++export interface IdentityClient {
++ resolve(input: ResolveInput): Promise<SubjectProjection | null>;
++ resolveBatch(input: ResolveBatchInput): Promise<Record<string, SubjectProjection | null>>;
++}
++export declare function createIdentityClient(client: OpenVibeClient, opts?: { baseUrl?: string }): IdentityClient;
ddiff --git a/types/index.d.ts b/types/index.d.ts
nnew file mode 100644
iindex 0000000..b324872
---- /dev/null
++++ b/types/index.d.ts
@@@ -0,0 +1,9 @@
++export * from './core';
++export * as auth from './auth';
++export * as registry from './registry';
++export * as identity from './identity';
++export * as modules from './modules';
++export * as events from './events';
++export * as realtime from './realtime';
++export * as media from './media';
++export * as community from './community';
ddiff --git a/types/media.d.ts b/types/media.d.ts
nnew file mode 100644
iindex 0000000..dd3a926
---- /dev/null
++++ b/types/media.d.ts
@@@ -0,0 +1,50 @@
++import type { OpenVibeClient } from './core';
++
++export interface MediaFile {
++ key: string;
++ app_id: string;
++ user_id: string | number | null;
++ original_name: string;
++ size: number;
++ mime: string;
++ sha256: string;
++ /** Media-relative, e.g. /f/<key> */
++ url: string;
++ /** Absolute, added by the SDK. */
++ public_url: string;
++ created_at: string;
++ deduplicated?: boolean;
++}
++export type UploadBody = Blob | ArrayBuffer | ArrayBufferView | string;
++
++export interface MediaUrls {
++ origin: string;
++ file(key: string): string;
++ vod(id: string | number): string;
++ clip(id: string | number): string;
++ thumbnail(name: string): string;
++ paste(slug: string): string;
++ pasteRaw(slug: string): string;
++ pasteScreenshot(slug: string): string;
++ vodTranscript(id: string | number): string;
++ liveTranscript(sel: string | number, opts?: { limit?: number; app?: string }): string;
++ liveFrame(sel: string | number, opts?: { width?: number; app?: string; format?: 'json' }): string;
++ absolute(path: string): string;
++}
++export declare function mediaUrls(origin?: string): MediaUrls;
++
++export interface MediaClient {
++ app: string;
++ urls: MediaUrls;
++ files: {
++ upload(file: UploadBody, opts?: { filename?: string; contentType?: string; userId?: string | number; actingUserId?: string | number | null; signal?: AbortSignal }): Promise<MediaFile>;
++ list(opts?: { limit?: number; offset?: number }): Promise<{ files: MediaFile[]; used_bytes: number; quota_bytes: number; limit: number; offset: number }>;
++ iterate(opts?: { pageSize?: number }): AsyncGenerator<MediaFile, void, unknown>;
++ get(key: string): Promise<MediaFile | null>;
++ delete(key: string, opts?: { actingUserId?: string | number | null }): Promise<boolean>;
++ };
++ upload: MediaClient['files']['upload'];
++}
++/** apiKey: the app's Media API key (server only). Without it the client's token (a service token with media.object.upload) is used. */
++export declare function createMediaClient(client: OpenVibeClient, opts: { app: string; apiKey?: string; actingUserId?: string | number; baseUrl?: string; publicOrigin?: string }): MediaClient;
++export declare const DEFAULT_PUBLIC_ORIGIN: string;
ddiff --git a/types/modules.d.ts b/types/modules.d.ts
nnew file mode 100644
iindex 0000000..4ce4d8f
---- /dev/null
++++ b/types/modules.d.ts
@@@ -0,0 +1,20 @@
++import type { OpenVibeClient, ModuleRecord, SubjectRef } from './core';
++
++export type Updater<T> = (data: T | undefined, record: ModuleRecord | null) => T | undefined | Promise<T | undefined>;
++
++export interface ModulesClient {
++ list(): Promise<{ subject: SubjectRef; modules: ModuleRecord[]; namespaces: Array<{ namespace: string; owner: string; userWritable: boolean; description?: string }> }>;
++ get(ns: string): Promise<ModuleRecord | null>;
++ put<T extends object>(ns: string, data: T, opts: { revision: number }): Promise<ModuleRecord>;
++ delete(ns: string): Promise<boolean>;
++ /** Read-modify-write; retries on 412 up to `attempts` (default 5). */
++ update<T extends object>(ns: string, fn: Updater<T>, opts?: { attempts?: number }): Promise<ModuleRecord | null>;
++ publicGet(ns: string, subjectId: string): Promise<{ subject: SubjectRef; namespace: string; version: number; data: Record<string, unknown> } | null>;
++ /** Service side (token with network.modules.read / .write for the namespace). */
++ forSubject: {
++ get(ns: string, subjectId: string): Promise<ModuleRecord | null>;
++ put<T extends object>(ns: string, subjectId: string, data: T, opts?: { revision?: number }): Promise<ModuleRecord>;
++ update<T extends object>(ns: string, subjectId: string, fn: Updater<T>, opts?: { attempts?: number }): Promise<ModuleRecord | null>;
++ };
++}
++export declare function createModulesClient(client: OpenVibeClient, opts?: { baseUrl?: string; maxAttempts?: number }): ModulesClient;
ddiff --git a/types/realtime.d.ts b/types/realtime.d.ts
nnew file mode 100644
iindex 0000000..470b3b2
---- /dev/null
++++ b/types/realtime.d.ts
@@@ -0,0 +1,34 @@
++import type { OpenVibeClient, EventEnvelope, FetchLike, TokenContext } from './core';
++
++export interface RealtimeGap { reason: 'retention' | 'replay_limit' | 'cursor_ahead' | string; from_seq: number; to_seq: number; latest_seq?: number; }
++export interface SubscribeOptions {
++ client?: OpenVibeClient;
++ /** Events origin or the full …/realtime/stream URL; default from the client's registry, else https://events.openvibe.network */
++ url?: string;
++ baseUrl?: string;
++ /** Resume after this seq (e.g. saved before a reload). */
++ lastEventId?: number | string | null;
++ onGap?: (gap: RealtimeGap) => void;
++ onOpen?: () => void;
++ onError?: (err: Error) => void;
++ /** A Bearer token forces the fetch transport (EventSource cannot send headers). */
++ token?: string;
++ getToken?: (ctx: TokenContext) => Promise<string | null | undefined> | string | null | undefined;
++ withCredentials?: boolean;
++ transport?: 'auto' | 'eventsource' | 'fetch';
++ reconnectDelayMs?: number;
++ maxReconnectDelayMs?: number;
++ fetch?: FetchLike;
++ EventSource?: any;
++}
++export interface RealtimeSubscription {
++ close(): void;
++ readonly lastEventId: number | null;
++ readonly transport: 'eventsource' | 'fetch';
++ readonly connected: boolean;
++ readonly closed: boolean;
++ readonly done: Promise<void>;
++}
++export declare function subscribe(topics: string | string[], onEvent: (event: EventEnvelope, meta: { seq: number }) => void, opts?: SubscribeOptions): RealtimeSubscription;
++export declare function createRealtimeClient(client: OpenVibeClient, defaults?: Omit<SubscribeOptions, 'client'>): { subscribe(topics: string | string[], onEvent: (event: EventEnvelope, meta: { seq: number }) => void, opts?: SubscribeOptions): RealtimeSubscription };
++export declare const DEFAULT_ORIGIN: string;
ddiff --git a/types/registry.d.ts b/types/registry.d.ts
nnew file mode 100644
iindex 0000000..c12c759
---- /dev/null
++++ b/types/registry.d.ts
@@@ -0,0 +1,17 @@
++import type { OpenVibeClient, Discovery, ServiceManifest, Capability, ModuleNamespace } from './core';
++
++export interface ServiceWithHealth extends ServiceManifest {
++ runtime: { status: string; http_status?: number; latency_ms?: number; checked_at: string | null; error?: string };
++}
++export interface RegistryClient {
++ descriptor(opts?: { force?: boolean }): Promise<Discovery>;
++ services(opts?: { status?: string }): Promise<ServiceWithHealth[]>;
++ service(id: string): Promise<(ServiceWithHealth & { capability_details: Capability[] }) | null>;
++ capabilities(opts?: { owner?: string }): Promise<Capability[]>;
++ capability(id: string): Promise<Capability | null>;
++ namespaces(): Promise<ModuleNamespace[]>;
++ contracts(): Promise<{ version: string; contracts: Array<Record<string, unknown>> }>;
++ topics(): Promise<unknown[]>;
++ domain(host: string): Promise<{ domain: string; service: ServiceWithHealth } | null>;
++}
++export declare function createRegistryClient(client: OpenVibeClient, opts?: { baseUrl?: string }): RegistryClient;
ddiff --git a/types/testing.d.ts b/types/testing.d.ts
nnew file mode 100644
iindex 0000000..6dfd7a3
---- /dev/null
++++ b/types/testing.d.ts
@@@ -0,0 +1,34 @@
++import type { FetchLike, EventEnvelope, Capability, ServiceManifest, ModuleNamespace } from './core';
++
++export interface MockGrant { capability: string; audience: string; namespaces?: string[]; }
++export interface MockUser { id: number | string; subject_id: string; username: string; display_name?: string; role?: string; legacy: Array<{ system: string; type?: string; id: string | number }>; }
++export interface MockPlatformOptions {
++ origins?: Partial<Record<'network' | 'events' | 'media' | 'community', string>>;
++ issuer?: string;
++ contractsVersion?: string;
++ clients?: Record<string, { secret: string; grants?: Array<MockGrant | [string, string, string[]?]>; redirectUris?: string[] }>;
++ users?: Array<Partial<MockUser>>;
++ mediaApps?: Record<string, { apiKey?: string }>;
++ namespaces?: ModuleNamespace[];
++ capabilities?: Capability[];
++ services?: ServiceManifest[];
++ realtimeRetryMs?: number;
++}
++export interface MockPlatform {
++ fetch: FetchLike;
++ origins: Record<'network' | 'events' | 'media' | 'community', string>;
++ issuer: string;
++ keys: { privateKey: object; publicKey: object; jwks: { keys: object[]; public_key: string; algorithm: 'RS256' } };
++ signUserToken(user: MockUser | string, opts?: { expiresInSec?: number; audience?: string[] }): string;
++ signServiceToken(clientId: string, opts: { audience: string; capabilities?: string[]; namespaces?: string[]; expiresInSec?: number }): string;
++ addClient(id: string, client: { secret: string; grants?: Array<MockGrant | [string, string, string[]?]>; redirectUris?: string[] }): void;
++ addUser(user?: Partial<MockUser>): MockUser;
++ /** What the Network does after the account chooser: returns an authorization code. */
++ authorize(opts: { clientId: string; redirectUri: string; subjectId?: string; codeChallenge?: string; codeChallengeMethod?: 'S256'; scope?: string }): string;
++ stats: { tokenRequests: number; requests: Array<{ method: string; url: string; headers: Record<string, string> }> };
++ state: { events: Array<{ seq: number; event: EventEnvelope; publisher: string }>; subscriptions: Map<string, any>; modules: Map<string, any>; files: Map<string, any>; users: Map<string, MockUser>; checkpoints: Map<string, number> };
++ publishEvent(envelope: Partial<EventEnvelope> & Pick<EventEnvelope, 'event_type' | 'source' | 'actor' | 'subject'>, publisher?: string): { event_id: string; seq: number; duplicate: boolean };
++ dropRealtime(): void;
++}
++export declare function createMockPlatform(opts?: MockPlatformOptions): MockPlatform;
++export declare const DEFAULT_ORIGINS: Record<'network' | 'events' | 'media' | 'community', string>;