openvibers/openvibe.inventory Commits
OpenVibe.Inventory: the network-wide inventory (ADR-054, plan T21 step 2) — kinds, definitions, instances, equipped and ledger; issuer grants within their namespace; equip; public and own reads; pages; inventory.* events; account export and deletion; Live's cosmetics seeded and scripts/import-live.js; never sold, bought, traded or converted
Changes
nnew file mode 100644@@@ -0,0 +1,36 @@++# OpenVibe.Inventory — environment (production: /etc/openvibe/inventory.env). Names only; never commit values.++++NODE_ENV=production++PORT=5030++HOST=127.0.0.1++BASE_URL=https://inventory.openvibe.network++TRUST_PROXY=2++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh inventory: the runtime role through PgBouncer,++# the owner on a direct connection (migrations), and Valkey (shared limit counters).++# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.++DATABASE_URL=++DATABASE_DIRECT_URL=++VALKEY_URL=++VALKEY_PREFIX=ov:inventory:++# Per-caller limits (server/http/caller-limits.js): requests one caller may make to the API per minute and per hour.++#INVENTORY_LIMITS_MINUTE=120++#INVENTORY_LIMITS_HOUR=3000++++# OpenVibe.Network: SSO (OAuth client `inventory`, PKCE S256) and its JWKS; app tokens for audience inventory.openvibe.network.++OV_NETWORK_URL=https://openvibe.network++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++OV_OAUTH_CLIENT_ID=inventory++OV_OAUTH_CLIENT_SECRET=++# OV_OAUTH_REDIRECT_URI=https://inventory.openvibe.network/auth/callback++# INVENTORY_AUDIENCE=inventory.openvibe.network++++# optional: defaults to true in production++# COOKIE_SECURE=true++++# OpenVibe.Events: the outbox relay publishes inventory.item.* and inventory.definition.published when EVENTS_URL and++# OV_OAUTH_CLIENT_SECRET are set (otherwise events wait in event_outbox). INVENTORY_EVENTS_SECRET signs the deliveries++# to POST /internal/events (account export and deletion; comma-separated for rotation, 32+ characters each). Unset: the++# route answers 503 and no subscription is created.++#EVENTS_URL=http://127.0.0.1:4300++#EVENTS_RELAY_INTERVAL_MS=2000++#INVENTORY_EVENTS_SECRET=nnew file mode 100644@@@ -0,0 +1,18 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++jobs:++ # install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).++ test:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176++ with:++ syntax-dirs: server scripts test++ contracts-service: inventory++ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk++ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'++ # Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).++ security:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176nnew file mode 100644@@@ -0,0 +1,11 @@++node_modules/++data/++dist/++.env++.env.*++!.env.example++*.log++*.pem++*.key++!server/data/++node_modulesnnew file mode 100644@@@ -0,0 +1,20 @@++# Changelog++++What changed in OpenVibe.Inventory, newest first. Each site also publishes its patch notes at /updates.++++## 0.2.0 — 2026-10-09++++- **The inventory (ADR-054, plan T21 step 2).**++ - **Storage:** kinds, definitions, instances, the equipped set and an append-only ledger++ (`migrations/0001_initial.sql`).++ - **Code:** the domain in `server/inventory/store.js`, the API in `server/http/api.js` (seven capabilities) and the++ pages: home, `/items`, `/items/:id`, `/me` and `/u/:subject`.++ - **Seed:** Live's five kinds and 70 cosmetics at boot.++ - **Events and account data:** `inventory.*` events through the outbox, and account export and deletion at++ `/internal/events`.++ - **Migration:** `scripts/import-live.js` moves Live's unlocks and equipped slots here and verifies them row for row.++ - **The money rule:** no route or column sells, buys, trades or converts an item, and a test holds that.++++## 0.1.0 — 2026-10-08++++- **First release:** the service starts from the OpenVibe skeleton — sign-in with OpenVibe.Network (OAuth 2 + PKCE), server-rendered pages through the OpenVibe Frame, the `/api/v1` mount with per-caller limits, crawl artifacts, PostgreSQL migrations, the deploy files and the test suite.nnew file mode 100644@@@ -0,0 +1,661 @@++ GNU AFFERO GENERAL PUBLIC LICENSE++ Version 3, 19 November 2007++++ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>++ Everyone is permitted to copy and distribute verbatim copies++ of this license document, but changing it is not allowed.++++ Preamble++++ The GNU Affero General Public License is a free, copyleft license for++software and other kinds of works, specifically designed to ensure++cooperation with the community in the case of network server software.++++ The licenses for most software and other practical works are designed++to take away your freedom to share and change the works. By contrast,++our General Public Licenses are intended to guarantee your freedom to++share and change all versions of a program--to make sure it remains free++software for all its users.++++ When we speak of free software, we are referring to freedom, not++price. Our General Public Licenses are designed to make sure that you++have the freedom to distribute copies of free software (and charge for++them if you wish), that you receive source code or can get it if you++want it, that you can change the software or use pieces of it in new++free programs, and that you know you can do these things.++++ Developers that use our General Public Licenses protect your rights++with two steps: (1) assert copyright on the software, and (2) offer++you this License which gives you legal permission to copy, distribute++and/or modify the software.++++ A secondary benefit of defending all users' freedom is that++improvements made in alternate versions of the program, if they++receive widespread use, become available for other developers to++incorporate. Many developers of free software are heartened and++encouraged by the resulting cooperation. However, in the case of++software used on network servers, this result may fail to come about.++The GNU General Public License permits making a modified version and++letting the public access it on a server without ever releasing its++source code to the public.++++ The GNU Affero General Public License is designed specifically to++ensure that, in such cases, the modified source code becomes available++to the community. It requires the operator of a network server to++provide the source code of the modified version running there to the++users of that server. Therefore, public use of a modified version, on++a publicly accessible server, gives the public access to the source++code of the modified version.++++ An older license, called the Affero General Public License and++published by Affero, was designed to accomplish similar goals. This is++a different license, not a version of the Affero GPL, but Affero has++released a new version of the Affero GPL which permits relicensing under++this license.++++ The precise terms and conditions for copying, distribution and++modification follow.++++ TERMS AND CONDITIONS++++ 0. Definitions.++++ "This License" refers to version 3 of the GNU Affero General Public License.++++ "Copyright" also means copyright-like laws that apply to other kinds of++works, such as semiconductor masks.++++ "The Program" refers to any copyrightable work licensed under this++License. Each licensee is addressed as "you". "Licensees" and++"recipients" may be individuals or organizations.++++ To "modify" a work means to copy from or adapt all or part of the work++in a fashion requiring copyright permission, other than the making of an++exact copy. The resulting work is called a "modified version" of the++earlier work or a work "based on" the earlier work.++++ A "covered work" means either the unmodified Program or a work based++on the Program.++++ To "propagate" a work means to do anything with it that, without++permission, would make you directly or secondarily liable for++infringement under applicable copyright law, except executing it on a++computer or modifying a private copy. Propagation includes copying,++distribution (with or without modification), making available to the++public, and in some countries other activities as well.++++ To "convey" a work means any kind of propagation that enables other++parties to make or receive copies. Mere interaction with a user through++a computer network, with no transfer of a copy, is not conveying.++++ An interactive user interface displays "Appropriate Legal Notices"++to the extent that it includes a convenient and prominently visible++feature that (1) displays an appropriate copyright notice, and (2)++tells the user that there is no warranty for the work (except to the++extent that warranties are provided), that licensees may convey the++work under this License, and how to view a copy of this License. If++the interface presents a list of user commands or options, such as a++menu, a prominent item in the list meets this criterion.++++ 1. Source Code.++++ The "source code" for a work means the preferred form of the work++for making modifications to it. "Object code" means any non-source++form of a work.++++ A "Standard Interface" means an interface that either is an official++standard defined by a recognized standards body, or, in the case of++interfaces specified for a particular programming language, one that++is widely used among developers working in that language.++++ The "System Libraries" of an executable work include anything, other++than the work as a whole, that (a) is included in the normal form of++packaging a Major Component, but which is not part of that Major++Component, and (b) serves only to enable use of the work with that++Major Component, or to implement a Standard Interface for which an++implementation is available to the public in source code form. A++"Major Component", in this context, means a major essential component++(kernel, window system, and so on) of the specific operating system++(if any) on which the executable work runs, or a compiler used to++produce the work, or an object code interpreter used to run it.++++ The "Corresponding Source" for a work in object code form means all++the source code needed to generate, install, and (for an executable++work) run the object code and to modify the work, including scripts to++control those activities. However, it does not include the work's++System Libraries, or general-purpose tools or generally available free++programs which are used unmodified in performing those activities but++which are not part of the work. For example, Corresponding Source++includes interface definition files associated with source files for++the work, and the source code for shared libraries and dynamically++linked subprograms that the work is specifically designed to require,++such as by intimate data communication or control flow between those++subprograms and other parts of the work.++++ The Corresponding Source need not include anything that users++can regenerate automatically from other parts of the Corresponding++Source.++++ The Corresponding Source for a work in source code form is that++same work.++++ 2. Basic Permissions.++++ All rights granted under this License are granted for the term of++copyright on the Program, and are irrevocable provided the stated++conditions are met. This License explicitly affirms your unlimited++permission to run the unmodified Program. The output from running a++covered work is covered by this License only if the output, given its++content, constitutes a covered work. This License acknowledges your++rights of fair use or other equivalent, as provided by copyright law.++++ You may make, run and propagate covered works that you do not++convey, without conditions so long as your license otherwise remains++in force. You may convey covered works to others for the sole purpose++of having them make modifications exclusively for you, or provide you++with facilities for running those works, provided that you comply with++the terms of this License in conveying all material for which you do++not control copyright. Those thus making or running the covered works++for you must do so exclusively on your behalf, under your direction++and control, on terms that prohibit them from making any copies of++your copyrighted material outside their relationship with you.++++ Conveying under any other circumstances is permitted solely under++the conditions stated below. Sublicensing is not allowed; section 10++makes it unnecessary.++++ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.++++ No covered work shall be deemed part of an effective technological++measure under any applicable law fulfilling obligations under article++11 of the WIPO copyright treaty adopted on 20 December 1996, or++similar laws prohibiting or restricting circumvention of such++measures.++++ When you convey a covered work, you waive any legal power to forbid++circumvention of technological measures to the extent such circumvention++is effected by exercising rights under this License with respect to++the covered work, and you disclaim any intention to limit operation or++modification of the work as a means of enforcing, against the work's++users, your or third parties' legal rights to forbid circumvention of++technological measures.++++ 4. Conveying Verbatim Copies.++++ You may convey verbatim copies of the Program's source code as you++receive it, in any medium, provided that you conspicuously and++appropriately publish on each copy an appropriate copyright notice;++keep intact all notices stating that this License and any++non-permissive terms added in accord with section 7 apply to the code;++keep intact all notices of the absence of any warranty; and give all++recipients a copy of this License along with the Program.++++ You may charge any price or no price for each copy that you convey,++and you may offer support or warranty protection for a fee.++++ 5. Conveying Modified Source Versions.++++ You may convey a work based on the Program, or the modifications to++produce it from the Program, in the form of source code under the++terms of section 4, provided that you also meet all of these conditions:++++ a) The work must carry prominent notices stating that you modified++ it, and giving a relevant date.++++ b) The work must carry prominent notices stating that it is++ released under this License and any conditions added under section++ 7. This requirement modifies the requirement in section 4 to++ "keep intact all notices".++++ c) You must license the entire work, as a whole, under this++ License to anyone who comes into possession of a copy. This++ License will therefore apply, along with any applicable section 7++ additional terms, to the whole of the work, and all its parts,++ regardless of how they are packaged. This License gives no++ permission to license the work in any other way, but it does not++ invalidate such permission if you have separately received it.++++ d) If the work has interactive user interfaces, each must display++ Appropriate Legal Notices; however, if the Program has interactive++ interfaces that do not display Appropriate Legal Notices, your++ work need not make them do so.++++ A compilation of a covered work with other separate and independent++works, which are not by their nature extensions of the covered work,++and which are not combined with it such as to form a larger program,++in or on a volume of a storage or distribution medium, is called an++"aggregate" if the compilation and its resulting copyright are not++used to limit the access or legal rights of the compilation's users++beyond what the individual works permit. Inclusion of a covered work++in an aggregate does not cause this License to apply to the other++parts of the aggregate.++++ 6. Conveying Non-Source Forms.++++ You may convey a covered work in object code form under the terms++of sections 4 and 5, provided that you also convey the++machine-readable Corresponding Source under the terms of this License,++in one of these ways:++++ a) Convey the object code in, or embodied in, a physical product++ (including a physical distribution medium), accompanied by the++ Corresponding Source fixed on a durable physical medium++ customarily used for software interchange.++++ b) Convey the object code in, or embodied in, a physical product++ (including a physical distribution medium), accompanied by a++ written offer, valid for at least three years and valid for as++ long as you offer spare parts or customer support for that product++ model, to give anyone who possesses the object code either (1) a++ copy of the Corresponding Source for all the software in the++ product that is covered by this License, on a durable physical++ medium customarily used for software interchange, for a price no++ more than your reasonable cost of physically performing this++ conveying of source, or (2) access to copy the++ Corresponding Source from a network server at no charge.++++ c) Convey individual copies of the object code with a copy of the++ written offer to provide the Corresponding Source. This++ alternative is allowed only occasionally and noncommercially, and++ only if you received the object code with such an offer, in accord++ with subsection 6b.++++ d) Convey the object code by offering access from a designated++ place (gratis or for a charge), and offer equivalent access to the++ Corresponding Source in the same way through the same place at no++ further charge. You need not require recipients to copy the++ Corresponding Source along with the object code. If the place to++ copy the object code is a network server, the Corresponding Source++ may be on a different server (operated by you or a third party)++ that supports equivalent copying facilities, provided you maintain++ clear directions next to the object code saying where to find the++ Corresponding Source. Regardless of what server hosts the++ Corresponding Source, you remain obligated to ensure that it is++ available for as long as needed to satisfy these requirements.++++ e) Convey the object code using peer-to-peer transmission, provided++ you inform other peers where the object code and Corresponding++ Source of the work are being offered to the general public at no++ charge under subsection 6d.++++ A separable portion of the object code, whose source code is excluded++from the Corresponding Source as a System Library, need not be++included in conveying the object code work.++++ A "User Product" is either (1) a "consumer product", which means any++tangible personal property which is normally used for personal, family,++or household purposes, or (2) anything designed or sold for incorporation++into a dwelling. In determining whether a product is a consumer product,++doubtful cases shall be resolved in favor of coverage. For a particular++product received by a particular user, "normally used" refers to a++typical or common use of that class of product, regardless of the status++of the particular user or of the way in which the particular user++actually uses, or expects or is expected to use, the product. A product++is a consumer product regardless of whether the product has substantial++commercial, industrial or non-consumer uses, unless such uses represent++the only significant mode of use of the product.++++ "Installation Information" for a User Product means any methods,++procedures, authorization keys, or other information required to install++and execute modified versions of a covered work in that User Product from++a modified version of its Corresponding Source. The information must++suffice to ensure that the continued functioning of the modified object++code is in no case prevented or interfered with solely because++modification has been made.++++ If you convey an object code work under this section in, or with, or++specifically for use in, a User Product, and the conveying occurs as++part of a transaction in which the right of possession and use of the++User Product is transferred to the recipient in perpetuity or for a++fixed term (regardless of how the transaction is characterized), the++Corresponding Source conveyed under this section must be accompanied++by the Installation Information. But this requirement does not apply++if neither you nor any third party retains the ability to install++modified object code on the User Product (for example, the work has++been installed in ROM).++++ The requirement to provide Installation Information does not include a++requirement to continue to provide support service, warranty, or updates++for a work that has been modified or installed by the recipient, or for++the User Product in which it has been modified or installed. Access to a++network may be denied when the modification itself materially and++adversely affects the operation of the network or violates the rules and++protocols for communication across the network.++++ Corresponding Source conveyed, and Installation Information provided,++in accord with this section must be in a format that is publicly++documented (and with an implementation available to the public in++source code form), and must require no special password or key for++unpacking, reading or copying.++++ 7. Additional Terms.++++ "Additional permissions" are terms that supplement the terms of this++License by making exceptions from one or more of its conditions.++Additional permissions that are applicable to the entire Program shall++be treated as though they were included in this License, to the extent++that they are valid under applicable law. If additional permissions++apply only to part of the Program, that part may be used separately++under those permissions, but the entire Program remains governed by++this License without regard to the additional permissions.++++ When you convey a copy of a covered work, you may at your option++remove any additional permissions from that copy, or from any part of++it. (Additional permissions may be written to require their own++removal in certain cases when you modify the work.) You may place++additional permissions on material, added by you to a covered work,++for which you have or can give appropriate copyright permission.++++ Notwithstanding any other provision of this License, for material you++add to a covered work, you may (if authorized by the copyright holders of++that material) supplement the terms of this License with terms:++++ a) Disclaiming warranty or limiting liability differently from the++ terms of sections 15 and 16 of this License; or++++ b) Requiring preservation of specified reasonable legal notices or++ author attributions in that material or in the Appropriate Legal++ Notices displayed by works containing it; or++++ c) Prohibiting misrepresentation of the origin of that material, or++ requiring that modified versions of such material be marked in++ reasonable ways as different from the original version; or++++ d) Limiting the use for publicity purposes of names of licensors or++ authors of the material; or++++ e) Declining to grant rights under trademark law for use of some++ trade names, trademarks, or service marks; or++++ f) Requiring indemnification of licensors and authors of that++ material by anyone who conveys the material (or modified versions of++ it) with contractual assumptions of liability to the recipient, for++ any liability that these contractual assumptions directly impose on++ those licensors and authors.++++ All other non-permissive additional terms are considered "further++restrictions" within the meaning of section 10. If the Program as you++received it, or any part of it, contains a notice stating that it is++governed by this License along with a term that is a further++restriction, you may remove that term. If a license document contains++a further restriction but permits relicensing or conveying under this++License, you may add to a covered work material governed by the terms++of that license document, provided that the further restriction does++not survive such relicensing or conveying.++++ If you add terms to a covered work in accord with this section, you++must place, in the relevant source files, a statement of the++additional terms that apply to those files, or a notice indicating++where to find the applicable terms.++++ Additional terms, permissive or non-permissive, may be stated in the++form of a separately written license, or stated as exceptions;++the above requirements apply either way.++++ 8. Termination.++++ You may not propagate or modify a covered work except as expressly++provided under this License. Any attempt otherwise to propagate or++modify it is void, and will automatically terminate your rights under++this License (including any patent licenses granted under the third++paragraph of section 11).++++ However, if you cease all violation of this License, then your++license from a particular copyright holder is reinstated (a)++provisionally, unless and until the copyright holder explicitly and++finally terminates your license, and (b) permanently, if the copyright++holder fails to notify you of the violation by some reasonable means++prior to 60 days after the cessation.++++ Moreover, your license from a particular copyright holder is++reinstated permanently if the copyright holder notifies you of the++violation by some reasonable means, this is the first time you have++received notice of violation of this License (for any work) from that++copyright holder, and you cure the violation prior to 30 days after++your receipt of the notice.++++ Termination of your rights under this section does not terminate the++licenses of parties who have received copies or rights from you under++this License. If your rights have been terminated and not permanently++reinstated, you do not qualify to receive new licenses for the same++material under section 10.++++ 9. Acceptance Not Required for Having Copies.++++ You are not required to accept this License in order to receive or++run a copy of the Program. Ancillary propagation of a covered work++occurring solely as a consequence of using peer-to-peer transmission++to receive a copy likewise does not require acceptance. However,++nothing other than this License grants you permission to propagate or++modify any covered work. These actions infringe copyright if you do++not accept this License. Therefore, by modifying or propagating a++covered work, you indicate your acceptance of this License to do so.++++ 10. Automatic Licensing of Downstream Recipients.++++ Each time you convey a covered work, the recipient automatically++receives a license from the original licensors, to run, modify and++propagate that work, subject to this License. You are not responsible++for enforcing compliance by third parties with this License.++++ An "entity transaction" is a transaction transferring control of an++organization, or substantially all assets of one, or subdividing an++organization, or merging organizations. If propagation of a covered++work results from an entity transaction, each party to that++transaction who receives a copy of the work also receives whatever++licenses to the work the party's predecessor in interest had or could++give under the previous paragraph, plus a right to possession of the++Corresponding Source of the work from the predecessor in interest, if++the predecessor has it or can get it with reasonable efforts.++++ You may not impose any further restrictions on the exercise of the++rights granted or affirmed under this License. For example, you may++not impose a license fee, royalty, or other charge for exercise of++rights granted under this License, and you may not initiate litigation++(including a cross-claim or counterclaim in a lawsuit) alleging that++any patent claim is infringed by making, using, selling, offering for++sale, or importing the Program or any portion of it.++++ 11. Patents.++++ A "contributor" is a copyright holder who authorizes use under this++License of the Program or a work on which the Program is based. The++work thus licensed is called the contributor's "contributor version".++++ A contributor's "essential patent claims" are all patent claims++owned or controlled by the contributor, whether already acquired or++hereafter acquired, that would be infringed by some manner, permitted++by this License, of making, using, or selling its contributor version,++but do not include claims that would be infringed only as a++consequence of further modification of the contributor version. For++purposes of this definition, "control" includes the right to grant++patent sublicenses in a manner consistent with the requirements of++this License.++++ Each contributor grants you a non-exclusive, worldwide, royalty-free++patent license under the contributor's essential patent claims, to++make, use, sell, offer for sale, import and otherwise run, modify and++propagate the contents of its contributor version.++++ In the following three paragraphs, a "patent license" is any express++agreement or commitment, however denominated, not to enforce a patent++(such as an express permission to practice a patent or covenant not to++sue for patent infringement). To "grant" such a patent license to a++party means to make such an agreement or commitment not to enforce a++patent against the party.++++ If you convey a covered work, knowingly relying on a patent license,++and the Corresponding Source of the work is not available for anyone++to copy, free of charge and under the terms of this License, through a++publicly available network server or other readily accessible means,++then you must either (1) cause the Corresponding Source to be so++available, or (2) arrange to deprive yourself of the benefit of the++patent license for this particular work, or (3) arrange, in a manner++consistent with the requirements of this License, to extend the patent++license to downstream recipients. "Knowingly relying" means you have++actual knowledge that, but for the patent license, your conveying the++covered work in a country, or your recipient's use of the covered work++in a country, would infringe one or more identifiable patents in that++country that you have reason to believe are valid.++++ If, pursuant to or in connection with a single transaction or++arrangement, you convey, or propagate by procuring conveyance of, a++covered work, and grant a patent license to some of the parties++receiving the covered work authorizing them to use, propagate, modify++or convey a specific copy of the covered work, then the patent license++you grant is automatically extended to all recipients of the covered++work and works based on it.++++ A patent license is "discriminatory" if it does not include within++the scope of its coverage, prohibits the exercise of, or is++conditioned on the non-exercise of one or more of the rights that are++specifically granted under this License. You may not convey a covered++work if you are a party to an arrangement with a third party that is++in the business of distributing software, under which you make payment++to the third party based on the extent of your activity of conveying++the work, and under which the third party grants, to any of the++parties who would receive the covered work from you, a discriminatory++patent license (a) in connection with copies of the covered work++conveyed by you (or copies made from those copies), or (b) primarily++for and in connection with specific products or compilations that++contain the covered work, unless you entered into that arrangement,++or that patent license was granted, prior to 28 March 2007.++++ Nothing in this License shall be construed as excluding or limiting++any implied license or other defenses to infringement that may++otherwise be available to you under applicable patent law.++++ 12. No Surrender of Others' Freedom.++++ If conditions are imposed on you (whether by court order, agreement or++otherwise) that contradict the conditions of this License, they do not++excuse you from the conditions of this License. If you cannot convey a++covered work so as to satisfy simultaneously your obligations under this++License and any other pertinent obligations, then as a consequence you may++not convey it at all. For example, if you agree to terms that obligate you++to collect a royalty for further conveying from those to whom you convey++the Program, the only way you could satisfy both those terms and this++License would be to refrain entirely from conveying the Program.++++ 13. Remote Network Interaction; Use with the GNU General Public License.++++ Notwithstanding any other provision of this License, if you modify the++Program, your modified version must prominently offer all users++interacting with it remotely through a computer network (if your version++supports such interaction) an opportunity to receive the Corresponding++Source of your version by providing access to the Corresponding Source++from a network server at no charge, through some standard or customary++means of facilitating copying of software. This Corresponding Source++shall include the Corresponding Source for any work covered by version 3++of the GNU General Public License that is incorporated pursuant to the++following paragraph.++++ Notwithstanding any other provision of this License, you have++permission to link or combine any covered work with a work licensed++under version 3 of the GNU General Public License into a single++combined work, and to convey the resulting work. The terms of this++License will continue to apply to the part which is the covered work,++but the work with which it is combined will remain governed by version++3 of the GNU General Public License.++++ 14. Revised Versions of this License.++++ The Free Software Foundation may publish revised and/or new versions of++the GNU Affero General Public License from time to time. Such new versions++will be similar in spirit to the present version, but may differ in detail to++address new problems or concerns.++++ Each version is given a distinguishing version number. If the++Program specifies that a certain numbered version of the GNU Affero General++Public License "or any later version" applies to it, you have the++option of following the terms and conditions either of that numbered++version or of any later version published by the Free Software++Foundation. If the Program does not specify a version number of the++GNU Affero General Public License, you may choose any version ever published++by the Free Software Foundation.++++ If the Program specifies that a proxy can decide which future++versions of the GNU Affero General Public License can be used, that proxy's++public statement of acceptance of a version permanently authorizes you++to choose that version for the Program.++++ Later license versions may give you additional or different++permissions. However, no additional obligations are imposed on any++author or copyright holder as a result of your choosing to follow a++later version.++++ 15. Disclaimer of Warranty.++++ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY++APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT++HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY++OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,++THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR++PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM++IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF++ALL NECESSARY SERVICING, REPAIR OR CORRECTION.++++ 16. Limitation of Liability.++++ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING++WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS++THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY++GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE++USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF++DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD++PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),++EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF++SUCH DAMAGES.++++ 17. Interpretation of Sections 15 and 16.++++ If the disclaimer of warranty and limitation of liability provided++above cannot be given local legal effect according to their terms,++reviewing courts shall apply local law that most closely approximates++an absolute waiver of all civil liability in connection with the++Program, unless a warranty or assumption of liability accompanies a++copy of the Program in return for a fee.++++ END OF TERMS AND CONDITIONS++++ How to Apply These Terms to Your New Programs++++ If you develop a new program, and you want it to be of the greatest++possible use to the public, the best way to achieve this is to make it++free software which everyone can redistribute and change under these terms.++++ To do so, attach the following notices to the program. It is safest++to attach them to the start of each source file to most effectively++state the exclusion of warranty; and each file should have at least++the "copyright" line and a pointer to where the full notice is found.++++ <one line to give the program's name and a brief idea of what it does.>++ Copyright (C) <year> <name of author>++++ This program is free software: you can redistribute it and/or modify++ it under the terms of the GNU Affero General Public License as published++ by the Free Software Foundation, either version 3 of the License, or++ (at your option) any later version.++++ This program is distributed in the hope that it will be useful,++ but WITHOUT ANY WARRANTY; without even the implied warranty of++ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the++ GNU Affero General Public License for more details.++++ You should have received a copy of the GNU Affero General Public License++ along with this program. If not, see <https://www.gnu.org/licenses/>.++++Also add information on how to contact you by electronic and paper mail.++++ If your software can interact with users remotely through a computer++network, you should also make sure that it provides a way for users to++get its source. For example, if your program is a web application, its++interface could display a "Source" link that leads users to an archive++of the code. There are many ways you could offer source, and different++solutions will be better for different programs; see section 13 for the++specific requirements.++++ You should also get your employer (if you work as a programmer) or school,++if any, to sign a "copyright disclaimer" for the program, if necessary.++For more information on this, and how to apply and follow the GNU AGPL, see++<https://www.gnu.org/licenses/>.nnew file mode 100644@@@ -0,0 +1,120 @@++# OpenVibe.Inventory++++> Your items, on every OpenVibe site.++++**Status:** alpha (ADR-054, plan track T21 step 2). The authority, its API, pages and Live's migration script are built++and tested; Live switches to it next.++**Domain:** `inventory.openvibe.network` · **Port:** 5030 · **Service id:** `inventory` · **Env prefix:** `INVENTORY`++**License:** AGPL-3.0 (same as every OpenVibe service).++++One inventory for an OpenVibe account, the way Steam's works across games. Sites, games and apps give items for what++people do; people keep them in one place and wear them on every site that shows their kind.++++## The model (ADR-054)++++- **Kind** (`live.name_effect`, `live.hat`, …): what its items carry (a JSON Schema), where one is equipped (slots) and++ which surfaces render it (chat, overlays, the profile, `game:<id>`). A new kind is a contract plus a renderer, never a++ new table. Kinds are registered at boot from [server/data/kinds.json](server/data/kinds.json).++- **Definition** (`itd_…`): one item of a kind, with a name, art (a Media image, a renderer token, an emoji), an honest++ rarity (common, uncommon, rare, epic, legendary), attributes valid against the kind, its issuer and an optional++ supply cap. Rarity, attributes and the cap are fixed once it is published.++- **Instance** (`inv_…`): one definition owned by one person, with its origin (granted, earned, migrated), its state++ (owned, consumed, revoked) and, under a cap, a serial number.++- **Equipped:** one instance per kind and slot.++- **Ledger:** an append-only row for every movement, with who did it and why.++++Only a kind's issuer defines and grants its items: `service:live` for `live.*`. **Nothing is sold, bought, traded or++converted into OpenCoins or Vibes**, and there are no paid random rewards, until a later ADR covers the legal basis and++Billing (ADR-054 §5). A test fails if a route or a column for that appears.++++## API (`/api/v1`, problem+json errors)++++| Route | Capability | |++|---|---|---|++| `GET /kinds`, `/kinds/:id` | `inventory.item.read` (public) | the kinds |++| `GET /definitions?kind=&issuer=`, `/definitions/:id` | `inventory.item.read` (public) | published items |++| `GET /people/:subject/items`, `/people/:subject/equipped` | `inventory.item.read` (public) | a person's owned items and what they wear |++| `GET /equipped?subjects=a,b,…` | `inventory.item.read` (public) | up to 100 equipped sets in one call (chat renders) |++| `GET /me/items` | `inventory.item.list` | the caller's own items, every state |++| `PUT /me/equipped` | `inventory.equip.manage` | equip or clear a slot |++| `POST /grants` | `inventory.item.grant` | an issuer grants an instance (idempotent per key; 409 `inventory.supply_exhausted`) |++| `POST /instances/:id/consume`, `/revoke` | `inventory.item.consume` | an issuer uses up or takes back an instance |++| `POST /definitions`, `PATCH /definitions/:id` | `inventory.definition.manage` | an issuer's items |++++A person acts for themself (their token or this site's session; a cookie write must come from this site). A service++acting for a person sends `X-OV-Subject: usr_…` and holds the route's capability. A public read needs no token; a++token that is presented must hold `inventory.item.read`.++++**Events** (openvibe-sdk outbox): `inventory.item.granted`, `.consumed`, `.revoked`, `.equipped`, `.unequipped` and++`inventory.definition.published`. **Account export and deletion** (ADR-033) arrive at the loopback++`POST /internal/events` (`INVENTORY_EVENTS_SECRET`; subscriptions created at boot). The export carries the person's++items, equipped set and history. The deletion removes their items and equipped set and keeps the ledger rows without++them, so supply counts stay true.++++## Pages++++`/` (what it is), `/items` (every item by kind), `/items/:id` (one item: where it shows, who gives it, how many exist),++`/me` (your items; Wear and Take off are plain forms), `/u/:subject` (a person's public inventory) and `/updates`.++Every page works without JavaScript.++++## Moving Live's cosmetics (ADR-054 §8)++++Live's 70 cosmetics are seeded as definitions issued by `service:live`, with Live's item ids kept as aliases. Then:++++```bash++node --env-file=/etc/openvibe/inventory.env scripts/import-live.js --live-env /etc/openvibe/live.env # dry run++node --env-file=/etc/openvibe/inventory.env scripts/import-live.js --live-env /etc/openvibe/live.env --apply # convert++node --env-file=/etc/openvibe/inventory.env scripts/import-live.js --live-env /etc/openvibe/live.env --verify # compare++```++++The script only reads Live's database, and never prints its URL. `--apply` keeps unlock times and grants nothing twice.++`--verify` compares every person's items and slots, row for row. Live then reads and writes through this API++(`INVENTORY_AUTHORITY=inventory`), and its tables are dropped after the N-1 window.++++## Configuration++++See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET` (the `inventory` OAuth client on the++Network), `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`. The database is the only required readiness check; the++Network signing key, the OAuth client and Valkey are optional (the service says so, per check, on `/api/ready`).++++## Development++++```bash++npm install++fnm exec --using=22 npm test # every test/*.test.js, on temp PGlite databases with a mock Network++fnm exec --using=22 npm run dev # http://localhost:5030++```++++Without `DATABASE_URL` development uses an embedded PGlite database in `data/pglite` (one process only). `npm run++test:pg` runs the same suite through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflows/ci.yml)).++++## Deploy (for the lead)++++- **Deploy:** `sudo ovhost deploy inventory` on the host (git checkout at `/opt/inventory.openvibe.network`, unit++ `openvibe-inventory.service` on 127.0.0.1:5030, env `/etc/openvibe/inventory.env`, database `ov_inventory` on the data role).++- **nginx:** [deploy/nginx/inventory.openvibe.network.conf](deploy/nginx/inventory.openvibe.network.conf), installed with `ov-vhost-install`.++- **Rollback:** ovhost puts the previous sha back by itself when `/api/ready` does not answer after the restart.++- **Network:** OAuth client `inventory`; grants `events.event.publish` and `events.subscription.manage` (openvibe.events),++ then the account grants; the issuers' grants (`live`: `inventory.item.grant`, `inventory.item.consume`,++ `inventory.definition.manage`, `inventory.item.list`, `inventory.equip.manage`, `inventory.item.read`).++- The vhost uses the `openvibe.network` wildcard certificate.++++## Security (threat notes)++++Reporting a vulnerability: [SECURITY.md](SECURITY.md).++++- Session tokens are httpOnly cookies; a FedCM assertion or an app or service token is never a session.++- Secrets live only in the env file; only environment variable names appear in code and docs, and no secret is logged.++- Request bodies are never logged.++- Nothing in a request may decide a URL this service fetches: a caller's URL goes to OpenVibe.Tools, whose own guard++ decides what may be fetched.++++---++++Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).++++<!-- versions:start -->++- openvibe-contracts: v0.121.0++- openvibe-sdk: v0.37.0++- openvibe-shared: v2.15.0++<!-- versions:end -->nnew file mode 100644@@@ -0,0 +1,21 @@++# Security policy++++OpenVibe is run in the open, and we want to hear about security problems before anyone else does.++++## Reporting a vulnerability++++Email **[email protected]** with "security" in the subject. Please include what you found, where (URL,++repository, file), how to reproduce it, and what an attacker could do with it. Do not open a public GitHub issue for++a vulnerability, and do not test against other people's accounts, data or streams.++++We reply within 7 days, keep you updated while we fix it, and credit you when the fix ships if you would like.++++## Scope++++Every OpenVibe service and site (openvibe.live, openvibe.network, openvibe.tools, openvibe.media, openvibe.community,++openvibe.games and the other openvibe.* domains) and every repository under github.com/OpenVibers. Machine-readable++contact details are at `/.well-known/security.txt` on each site.++++## Supported versions++++Only the current `main` branch, which is what runs in production, receives fixes.nnew file mode 100644@@@ -0,0 +1,50 @@++{++ "stage": "alpha",++ "code": true,++ "wave": "W20",++ "plan": "Plan track T21 (the OpenVibe inventory), ADR-054: step 2, the authority with Live's cosmetics as the first kinds",++ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",++ "port": "5030",++ "domain": "inventory.openvibe.network",++ "contracts": "openvibe-contracts v0.121.0",++ "sdk": "openvibe-sdk v0.37.0",++ "shared": "openvibe-shared v2.15.0",++ "packages": [++ "openvibe-contracts v0.121.0",++ "openvibe-sdk v0.37.0",++ "openvibe-shared v2.15.0"++ ],++ "summary": "OpenVibe.Inventory — one inventory for an OpenVibe account (ADR-054). Item kinds (a contract plus a renderer per surface), definitions, instances, the equipped set and an append-only ledger in its own PostgreSQL database. Issuers (services, apps) grant only within their namespace; people see and equip what they own; every movement is a ledger row and an inventory.* event. Nothing is sold, bought, traded or converted. Live's name effects, particles, hats and voices are the first kinds (70 definitions seeded at boot), and scripts/import-live.js moves Live's unlocks and equipped slots here, row for row.",++ "works": [++ "Kinds and definitions seeded at boot from data/kinds.json and data/live-definitions.json (Live's catalog, its item ids kept as aliases)",++ "Grants by an issuer within its namespace: idempotent per key, supply caps with serial numbers, a non-stackable item answers the one already owned; consume and revoke",++ "Equip one item per slot (a person, or a service acting for them with X-OV-Subject); a batch read of up to 100 equipped sets in one query",++ "Public reads (kinds, definitions, a person's inventory and equipped set) and the person's own full inventory",++ "Definitions created and edited by their issuer; rarity, attributes and supply fixed once published; attributes validated against the kind's schema",++ "Pages: home, every item by kind, one item, your inventory with plain equip forms, a person's public inventory, /updates",++ "Events (inventory.item.granted/consumed/revoked/equipped/unequipped, inventory.definition.published) through the openvibe-sdk outbox",++ "Account export and deletion (ADR-033) at the loopback POST /internal/events, subscriptions created at boot",++ "scripts/import-live.js: dry run, --apply (origin migrated, unlock times kept, idempotent) and --verify (row for row)"++ ],++ "notYet": [++ "Live reading and writing through Inventory (INVENTORY_AUTHORITY), then Live's tables dropped (ADR-054 §8)",++ "Profiles on Network showing the showcase; equip in Chat, Community and Games",++ "Creator submissions and staff review (the Workshop, ADR-054 §6)",++ "Trading and a market: not before a money ADR (ADR-054 §5)",++ "Account merge (ADR-029): instances moving to the surviving subject"++ ],++ "capabilitiesRegistered": true,++ "serviceManifestRegistered": true,++ "deployed": false,++ "placeholderInSites": false,++ "features": [++ "item kinds",++ "definitions",++ "grants",++ "equip",++ "public inventory",++ "Live migration",++ "account export and deletion"++ ],++ "updated": "2026-10-09"++}nnew file mode 100644@@@ -0,0 +1,104 @@++## ═══════════════════════════════════════════════════════════════++## inventory.openvibe.network — Nginx configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/inventory.openvibe.network.conf (ov-vhost-install) — keep them in sync and reload++## nginx after changing it. The Node app (port 5030, systemd unit openvibe-inventory) renders every page and serves++## the API; nginx terminates TLS with the openvibe.network wildcard certificate (*.openvibe.network: a repo vhost uses++## the certificate that really covers its name, or Cloudflare answers 526), rate-limits sign-in, the API and the equip++## form, keeps /internal/ (OpenVibe.Events deliveries) off the internet, and compresses text. There is no www. name:++## the wildcard does not cover a second level.++##++## No proxy_cache: the app decides cacheability per response (public for the pages rendered for anonymous++## visitors; private, no-store for everything personal). Never log request bodies here.++## ═══════════════════════════════════════════════════════════════++++limit_req_zone $binary_remote_addr zone=inventory_auth:10m rate=10r/m;++limit_req_zone $binary_remote_addr zone=inventory_api:10m rate=5r/s;++limit_req_zone $binary_remote_addr zone=inventory_forms:10m rate=1r/s;++++server {++ listen 80;++ listen [::]:80;++ server_name inventory.openvibe.network;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://inventory.openvibe.network$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name inventory.openvibe.network;++++ ssl_certificate /etc/letsencrypt/live/openvibe.network/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.network/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/inventory.openvibe.network.access.log;++ error_log /var/log/nginx/inventory.openvibe.network.error.log;++++ # The API's JSON body cap is 64 KB; leave room for the headers.++ client_max_body_size 128k;++++ add_header X-Content-Type-Options nosniff always;++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++++ gzip on;++ gzip_proxied any;++ gzip_vary on;++ gzip_comp_level 5;++ gzip_min_length 1024;++ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;++++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++++ # ── Network SSO session endpoints ───────────────────────++ # The navbar's session probe runs on every page view: it gets the API's budget, not sign-in's.++ location = /auth/me {++ limit_req zone=inventory_api burst=30 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5030;++ }++++ location /auth/ {++ limit_req zone=inventory_auth burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5030;++ }++++ # ── API ─────────────────────────────────────────────────++ location /api/ {++ limit_req zone=inventory_api burst=20 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5030;++ }++++ # ── The equip form (POST /me/equip) ─────────────────────++ location = /me/equip {++ limit_req zone=inventory_forms burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5030;++ }++++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5030/metrics) ──++ location = /metrics { return 404; }++++ # ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──++ # Events posts to http://127.0.0.1:5030/internal/events directly; never proxied from outside (ADR-033).++ location /internal/ { return 404; }++++ # ── Pages, docs, robots.txt, sitemap, static assets ─────++ location / {++ proxy_pass http://127.0.0.1:5030;++ }++++ location ~ /\.(?!well-known) {++ deny all;++ }++}nnew file mode 100644@@@ -0,0 +1,30 @@++[Unit]++Description=OpenVibe.Inventory — Your items, on every OpenVibe site.++# Network (sign-in, JWKS) should be up first, but OpenVibe.Inventory degrades honestly without it: the pages and the API's++# public routes serve; sign-in and the guarded routes wait for the signing key (see /api/ready).++After=network.target postgresql.service pgbouncer.service valkey-server.service++Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/inventory.openvibe.network++EnvironmentFile=/etc/openvibe/inventory.env++Environment=NODE_ENV=production++Environment=PORT=5030++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/inventory.openvibe.network/server/index.js++Restart=always++RestartSec=5++KillSignal=SIGTERM++# In-flight requests get the 10 s drain.++TimeoutStopSec=15++NoNewPrivileges=true++PrivateTmp=true++ProtectSystem=strict++ProtectHome=true++# OpenVibe.Inventory's own database is PostgreSQL on the host's data role; the code tree stays read-only.++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,128 @@++-- phase: expand++-- OpenVibe.Inventory (ADR-054): item kinds, definitions, instances, the equipped set and the append-only ledger, plus++-- the grant receipts, the event outbox and the account-data receipts. Nothing written here is ever a credential, a key++-- or a token. Money never enters this database: nothing is sold, bought, traded or converted (ADR-054 §5).++++-- A kind: what its items carry (attribute_schema, a JSON Schema), where one can be equipped (slots) and which surfaces++-- render it (inventory.kind@1). issuer owns the kind's namespace (service:live for live.*).++CREATE TABLE inventory_kinds (++ id text COLLATE "C" PRIMARY KEY, -- live.name_effect++ name text NOT NULL,++ description text,++ slots jsonb NOT NULL DEFAULT '[]',++ surfaces jsonb NOT NULL DEFAULT '[]',++ attribute_schema jsonb NOT NULL DEFAULT '{"type":"object"}',++ stackable boolean NOT NULL DEFAULT false,++ issuer text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL++);++++-- A definition: one item of a kind (inventory.definition@1).++CREATE TABLE inventory_definitions (++ id text COLLATE "C" PRIMARY KEY, -- itd_<ULID>++ kind text COLLATE "C" NOT NULL REFERENCES inventory_kinds(id),++ name text NOT NULL,++ description text,++ art jsonb NOT NULL,++ rarity text NOT NULL CHECK (rarity IN ('common', 'uncommon', 'rare', 'epic', 'legendary')),++ attributes jsonb NOT NULL DEFAULT '{}',++ issuer text COLLATE "C" NOT NULL, -- service:live | app:app_… | user:usr_…++ supply_cap integer CHECK (supply_cap IS NULL OR supply_cap > 0),++ supply_issued integer NOT NULL DEFAULT 0 CHECK (supply_issued >= 0),++ status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'in_review', 'published', 'retired')),++ credit_subject text COLLATE "C",++ credit_name text,++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL,++ published_at text COLLATE "C",++ CHECK (supply_cap IS NULL OR supply_issued <= supply_cap)++);++CREATE INDEX inventory_definitions_kind ON inventory_definitions (kind, status, name);++CREATE INDEX inventory_definitions_issuer ON inventory_definitions (issuer, created_at DESC);++++-- Earlier ids of a definition, per issuer (Live's fx_rainbow): how Live and the migration find an item.++CREATE TABLE inventory_definition_aliases (++ issuer text COLLATE "C" NOT NULL,++ alias text COLLATE "C" NOT NULL,++ definition_id text COLLATE "C" NOT NULL REFERENCES inventory_definitions(id) ON DELETE CASCADE,++ PRIMARY KEY (issuer, alias)++);++++-- An instance: one definition owned by one person (inventory.instance@1).++CREATE TABLE inventory_instances (++ id text COLLATE "C" PRIMARY KEY, -- inv_<ULID>++ definition_id text COLLATE "C" NOT NULL REFERENCES inventory_definitions(id),++ owner_subject text COLLATE "C" NOT NULL, -- usr_…++ origin text NOT NULL CHECK (origin IN ('granted', 'earned', 'migrated')),++ state text NOT NULL DEFAULT 'owned' CHECK (state IN ('owned', 'consumed', 'revoked')),++ attributes jsonb NOT NULL DEFAULT '{}',++ serial integer CHECK (serial IS NULL OR serial > 0),++ acquired_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL++);++CREATE INDEX inventory_instances_owner ON inventory_instances (owner_subject, acquired_at DESC, id DESC);++CREATE INDEX inventory_instances_definition ON inventory_instances (definition_id, owner_subject) WHERE state = 'owned';++++-- One grant per (issuer, idempotency key): a repeat answers the instance already granted.++CREATE TABLE inventory_grants (++ issuer text COLLATE "C" NOT NULL,++ idempotency_key text COLLATE "C" NOT NULL,++ instance_id text COLLATE "C" NOT NULL REFERENCES inventory_instances(id) ON DELETE CASCADE,++ created_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (issuer, idempotency_key)++);++++-- What a person has equipped: one instance per (kind, slot).++CREATE TABLE inventory_equipped (++ owner_subject text COLLATE "C" NOT NULL,++ kind text COLLATE "C" NOT NULL,++ slot text COLLATE "C" NOT NULL,++ instance_id text COLLATE "C" NOT NULL REFERENCES inventory_instances(id) ON DELETE CASCADE,++ updated_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (owner_subject, kind, slot)++);++CREATE INDEX inventory_equipped_instance ON inventory_equipped (instance_id);++++-- Every movement, append-only: granted, earned, migrated, equipped, unequipped, consumed, revoked, erased. A deleted++-- account's rows keep their counts and lose their subject (ADR-054 §7).++CREATE TABLE inventory_ledger (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ at text COLLATE "C" NOT NULL,++ action text NOT NULL,++ owner_subject text COLLATE "C",++ instance_id text COLLATE "C",++ definition_id text COLLATE "C",++ actor text COLLATE "C" NOT NULL, -- user:usr_… | service:… | app:app_…++ reason text,++ detail jsonb NOT NULL DEFAULT '{}'++);++CREATE INDEX inventory_ledger_owner ON inventory_ledger (owner_subject, id DESC);++CREATE INDEX inventory_ledger_instance ON inventory_ledger (instance_id, id);++++-- openvibe-sdk service outbox (inventory.item.*, inventory.definition.published).++CREATE TABLE IF NOT EXISTS event_outbox (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ event_id text NOT NULL UNIQUE,++ envelope jsonb NOT NULL,++ traceparent text,++ created_at bigint NOT NULL,++ attempts integer NOT NULL DEFAULT 0,++ next_attempt_at bigint NOT NULL DEFAULT 0,++ sent_at bigint,++ seq bigint,++ rejected_at bigint,++ last_error text++);++CREATE INDEX IF NOT EXISTS event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;++CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;++++-- openvibe-sdk/account-data receipts (ADR-033): a redelivered export or deletion changes nothing.++CREATE TABLE IF NOT EXISTS account_data_events (++ id TEXT PRIMARY KEY,++ kind TEXT NOT NULL,++ subject TEXT NOT NULL,++ outcome JSONB,++ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),++ sent_at TIMESTAMPTZ++);nnew file mode 100644@@@ -0,0 +1,1405 @@++{++ "name": "openvibe-inventory",++ "version": "0.2.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-inventory",++ "version": "0.2.0",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.20.0",++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "express-rate-limit": "^7.5.0",++ "helmet": "^8.1.0",++ "iovalkey": "^0.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.121.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",++ "pg": "^8.23.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"++ },++ "engines": {++ "node": ">=22.0.0"++ }++ },++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.8",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",++ "devOptional": true,++ "license": "Apache-2.0"++ },++ "node_modules/@iovalkey/commands": {++ "version": "0.1.0",++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",++ "license": "MIT"++ },++ "node_modules/accepts": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",++ "license": "MIT",++ "dependencies": {++ "mime-types": "~2.1.34",++ "negotiator": "0.6.3"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/ajv": {++ "version": "8.20.0",++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",++ "license": "MIT",++ "dependencies": {++ "fast-deep-equal": "^3.1.3",++ "fast-uri": "^3.0.1",++ "json-schema-traverse": "^1.0.0",++ "require-from-string": "^2.0.2"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/epoberezkin"++ }++ },++ "node_modules/ajv-formats": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",++ "license": "MIT",++ "dependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependenciesMeta": {++ "ajv": {++ "optional": true++ }++ }++ },++ "node_modules/array-flatten": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",++ "license": "MIT"++ },++ "node_modules/body-parser": {++ "version": "1.20.8",++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "content-type": "~1.0.5",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "~1.2.0",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "on-finished": "~2.4.1",++ "qs": "~6.16.0",++ "raw-body": "~2.5.3",++ "type-is": "~1.6.18",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/buffer-equal-constant-time": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",++ "license": "BSD-3-Clause"++ },++ "node_modules/bytes": {++ "version": "3.1.2",++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/call-bind-apply-helpers": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/call-bound": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "get-intrinsic": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/cluster-key-slot": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/content-disposition": {++ "version": "0.5.4",++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "5.2.1"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/content-type": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie": {++ "version": "0.7.2",++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie-parser": {++ "version": "1.4.7",++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",++ "license": "MIT",++ "dependencies": {++ "cookie": "0.7.2",++ "cookie-signature": "1.0.6"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/cookie-signature": {++ "version": "1.0.6",++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",++ "license": "MIT"++ },++ "node_modules/debug": {++ "version": "2.6.9",++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",++ "license": "MIT",++ "dependencies": {++ "ms": "2.0.0"++ }++ },++ "node_modules/denque": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10"++ }++ },++ "node_modules/depd": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/destroy": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/dotenv": {++ "version": "16.6.1",++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=12"++ },++ "funding": {++ "url": "https://dotenvx.com"++ }++ },++ "node_modules/dunder-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.1",++ "es-errors": "^1.3.0",++ "gopd": "^1.2.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/ecdsa-sig-formatter": {++ "version": "1.0.11",++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/ee-first": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",++ "license": "MIT"++ },++ "node_modules/encodeurl": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/es-define-property": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-errors": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-object-atoms": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/escape-html": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",++ "license": "MIT"++ },++ "node_modules/etag": {++ "version": "1.8.1",++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/express": {++ "version": "4.22.3",++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",++ "license": "MIT",++ "dependencies": {++ "accepts": "~1.3.8",++ "array-flatten": "1.1.1",++ "body-parser": "~1.20.5",++ "content-disposition": "~0.5.4",++ "content-type": "~1.0.4",++ "cookie": "~0.7.1",++ "cookie-signature": "~1.0.6",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "finalhandler": "~1.3.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.0",++ "merge-descriptors": "1.0.3",++ "methods": "~1.1.2",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "path-to-regexp": "~0.1.13",++ "proxy-addr": "~2.0.7",++ "qs": "~6.16.0",++ "range-parser": "~1.2.1",++ "safe-buffer": "5.2.1",++ "send": "~0.19.0",++ "serve-static": "~1.16.2",++ "setprototypeof": "1.2.0",++ "statuses": "~2.0.1",++ "type-is": "~1.6.18",++ "utils-merge": "1.0.1",++ "vary": "~1.1.2"++ },++ "engines": {++ "node": ">= 0.10.0"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/express-rate-limit": {++ "version": "7.5.1",++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",++ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",++ "license": "MIT",++ "engines": {++ "node": ">= 16"++ },++ "funding": {++ "url": "https://github.com/sponsors/express-rate-limit"++ },++ "peerDependencies": {++ "express": ">= 4.11"++ }++ },++ "node_modules/fast-deep-equal": {++ "version": "3.1.3",++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",++ "license": "MIT"++ },++ "node_modules/fast-uri": {++ "version": "3.1.8",++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fastify"++ },++ {++ "type": "opencollective",++ "url": "https://opencollective.com/fastify"++ }++ ],++ "license": "BSD-3-Clause"++ },++ "node_modules/finalhandler": {++ "version": "1.3.2",++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "statuses": "~2.0.2",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/forwarded": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/fresh": {++ "version": "0.5.2",++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/function-bind": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-intrinsic": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "es-define-property": "^1.0.1",++ "es-errors": "^1.3.0",++ "es-object-atoms": "^1.1.1",++ "function-bind": "^1.1.2",++ "get-proto": "^1.0.1",++ "gopd": "^1.2.0",++ "has-symbols": "^1.1.0",++ "hasown": "^2.0.2",++ "math-intrinsics": "^1.1.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",++ "license": "MIT",++ "dependencies": {++ "dunder-proto": "^1.0.1",++ "es-object-atoms": "^1.0.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/gopd": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/has-symbols": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/hasown": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",++ "license": "MIT",++ "dependencies": {++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/helmet": {++ "version": "8.3.0",++ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",++ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",++ "license": "MIT",++ "engines": {++ "node": ">=18.0.0"++ },++ "funding": {++ "url": "https://github.com/sponsors/EvanHahn"++ }++ },++ "node_modules/http-errors": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",++ "license": "MIT",++ "dependencies": {++ "depd": "~2.0.0",++ "inherits": "~2.0.4",++ "setprototypeof": "~1.2.0",++ "statuses": "~2.0.2",++ "toidentifier": "~1.0.1"++ },++ "engines": {++ "node": ">= 0.8"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/iconv-lite": {++ "version": "0.4.24",++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",++ "license": "MIT",++ "dependencies": {++ "safer-buffer": ">= 2.1.2 < 3"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/inherits": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",++ "license": "ISC"++ },++ "node_modules/iovalkey": {++ "version": "0.4.0",++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",++ "license": "MIT",++ "dependencies": {++ "@iovalkey/commands": "^0.1.0",++ "cluster-key-slot": "^1.1.0",++ "debug": "^4.3.4",++ "denque": "^2.1.0",++ "lodash.defaults": "^4.2.0",++ "lodash.isarguments": "^3.1.0",++ "redis-errors": "^1.2.0",++ "redis-parser": "^3.0.0",++ "standard-as-callback": "^2.1.0"++ },++ "engines": {++ "node": ">=18.12.0"++ }++ },++ "node_modules/iovalkey/node_modules/debug": {++ "version": "4.4.3",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/iovalkey/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/ipaddr.js": {++ "version": "1.9.1",++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.10"++ }++ },++ "node_modules/json-schema-traverse": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",++ "license": "MIT"++ },++ "node_modules/jsonwebtoken": {++ "version": "9.0.3",++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",++ "license": "MIT",++ "dependencies": {++ "jws": "^4.0.1",++ "lodash.includes": "^4.3.0",++ "lodash.isboolean": "^3.0.3",++ "lodash.isinteger": "^4.0.4",++ "lodash.isnumber": "^3.0.3",++ "lodash.isplainobject": "^4.0.6",++ "lodash.isstring": "^4.0.1",++ "lodash.once": "^4.0.0",++ "ms": "^2.1.1",++ "semver": "^7.5.4"++ },++ "engines": {++ "node": ">=12",++ "npm": ">=6"++ }++ },++ "node_modules/jsonwebtoken/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/jwa": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",++ "license": "MIT",++ "dependencies": {++ "buffer-equal-constant-time": "^1.0.1",++ "ecdsa-sig-formatter": "1.0.11",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/jws": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",++ "license": "MIT",++ "dependencies": {++ "jwa": "^2.0.1",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/lodash.defaults": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",++ "license": "MIT"++ },++ "node_modules/lodash.includes": {++ "version": "4.3.0",++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",++ "license": "MIT"++ },++ "node_modules/lodash.isarguments": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",++ "license": "MIT"++ },++ "node_modules/lodash.isboolean": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",++ "license": "MIT"++ },++ "node_modules/lodash.isinteger": {++ "version": "4.0.4",++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",++ "license": "MIT"++ },++ "node_modules/lodash.isnumber": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",++ "license": "MIT"++ },++ "node_modules/lodash.isplainobject": {++ "version": "4.0.6",++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",++ "license": "MIT"++ },++ "node_modules/lodash.isstring": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",++ "license": "MIT"++ },++ "node_modules/lodash.once": {++ "version": "4.1.1",++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",++ "license": "MIT"++ },++ "node_modules/math-intrinsics": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/media-typer": {++ "version": "0.3.0",++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/merge-descriptors": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/methods": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime": {++ "version": "1.6.0",++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",++ "license": "MIT",++ "bin": {++ "mime": "cli.js"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/mime-db": {++ "version": "1.52.0",++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime-types": {++ "version": "2.1.35",++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",++ "license": "MIT",++ "dependencies": {++ "mime-db": "1.52.0"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/ms": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",++ "license": "MIT"++ },++ "node_modules/negotiator": {++ "version": "0.6.3",++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/object-inspect": {++ "version": "1.13.4",++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/on-finished": {++ "version": "2.4.1",++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",++ "license": "MIT",++ "dependencies": {++ "ee-first": "1.1.1"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/openvibe-contracts": {++ "version": "0.121.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.121.0",++ "integrity": "sha512-z33smiDvUJ8uPsA4OFLI5CF/wHQV4wi0ijtNBKWQ2bhv8ExzwUPs1y48hPN0dB7KtRc1mi3iJrG7A4dufGOblg==",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1"++ },++ "bin": {++ "openvibe-contracts-check": "bin/check-service.js"++ },++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/openvibe-sdk": {++ "version": "0.37.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",++ "integrity": "sha512-afojId2KzOL4ZZrpwIeIAYUaX24ZOC3Nt+bdd1HSXm7aislt8WQNS62i8/d8W3udLr6rmSG29SjmnaRHUGfd4Q==",++ "license": "MIT",++ "engines": {++ "node": ">=20"++ },++ "peerDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "better-sqlite3": ">=11",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0"++ },++ "peerDependenciesMeta": {++ "@electric-sql/pglite": {++ "optional": true++ },++ "better-sqlite3": {++ "optional": true++ },++ "iovalkey": {++ "optional": true++ },++ "pg": {++ "optional": true++ }++ }++ },++ "node_modules/openvibe-shared": {++ "version": "2.15.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",++ "integrity": "sha512-xApTyiUBVDQLTdnKEjjr/9uUU4+T5gofz9DqXv0MrLVINmW88uwbfoEht3kAlG9mp1792U6kIFEblJDHn2wyMQ==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ },++ "bin": {++ "openvibe-build-assets": "scripts/build-assets.js"++ }++ },++ "node_modules/parseurl": {++ "version": "1.3.3",++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/path-to-regexp": {++ "version": "0.1.13",++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",++ "license": "MIT"++ },++ "node_modules/pg": {++ "version": "8.23.1",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",++ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.1",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.1",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.1"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.1",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",++ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.1",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",++ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.1",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",++ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/proxy-addr": {++ "version": "2.0.8",++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",++ "license": "MIT",++ "dependencies": {++ "forwarded": "0.2.0",++ "ipaddr.js": "1.9.1"++ },++ "engines": {++ "node": ">= 0.10"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/qs": {++ "version": "6.16.0",++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",++ "license": "BSD-3-Clause",++ "dependencies": {++ "es-define-property": "^1.0.1",++ "side-channel": "^1.1.1"++ },++ "engines": {++ "node": ">=0.6"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/range-parser": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/raw-body": {++ "version": "2.5.3",++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/safe-buffer": {++ "version": "5.2.1",++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/safer-buffer": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",++ "license": "MIT"++ },++ "node_modules/semver": {++ "version": "7.8.5",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",++ "license": "ISC",++ "bin": {++ "semver": "bin/semver.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/send": {++ "version": "0.19.2",++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "1.2.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.1",++ "mime": "1.6.0",++ "ms": "2.1.3",++ "on-finished": "~2.4.1",++ "range-parser": "~1.2.1",++ "statuses": "~2.0.2"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/send/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/serve-static": {++ "version": "1.16.3",++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",++ "license": "MIT",++ "dependencies": {++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "parseurl": "~1.3.3",++ "send": "~0.19.1"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/setprototypeof": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",++ "license": "ISC"++ },++ "node_modules/side-channel": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4",++ "side-channel-list": "^1.0.1",++ "side-channel-map": "^1.0.1",++ "side-channel-weakmap": "^1.0.2"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-list": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-map": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-weakmap": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3",++ "side-channel-map": "^1.0.1"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },++ "node_modules/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },++ "node_modules/statuses": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/toidentifier": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",++ "license": "MIT",++ "engines": {++ "node": ">=0.6"++ }++ },++ "node_modules/type-is": {++ "version": "1.6.18",++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",++ "license": "MIT",++ "dependencies": {++ "media-typer": "0.3.0",++ "mime-types": "~2.1.24"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/unpipe": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/utils-merge": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4.0"++ }++ },++ "node_modules/vary": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,37 @@++{++ "name": "openvibe-inventory",++ "version": "0.2.0",++ "private": true,++ "description": "OpenVibe.Inventory — Your items, on every OpenVibe site.",++ "main": "server/index.js",++ "scripts": {++ "start": "node server/index.js",++ "dev": "NODE_ENV=development node server/index.js",++ "test": "node test/run.js",++ "test:pg": "INVENTORY_TEST_STORE=pg node test/run.js"++ },++ "keywords": [++ "openvibe"++ ],++ "author": "OpenVibers",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.20.0",++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "express-rate-limit": "^7.5.0",++ "helmet": "^8.1.0",++ "iovalkey": "^0.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.121.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",++ "pg": "^8.23.0"++ },++ "engines": {++ "node": ">=22.0.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"++ }++}nnew file mode 100644@@@ -0,0 +1,103 @@++/* OpenVibe.Inventory — theme-token driven (openvibe-shared presets). Every page works without JavaScript. */++:root { color-scheme: dark; }++* { box-sizing: border-box; }++body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }++a { color: var(--accent-light, #60a5fa); }++a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, summary:focus-visible { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }++.skip { position: absolute; left: -9999px; }++.skip:focus { left: 16px; top: 8px; background: var(--bg-card, #131c2e); padding: 8px 12px; z-index: 10; }++.account-bar { max-width: 1080px; margin: 0 auto; padding: 6px 16px 0; text-align: right; font-size: 14px; color: var(--text-secondary, #96a7c2); }++.page { max-width: 1080px; margin: 0 auto; padding: 16px 16px 64px; min-height: 60vh; }++h1 { font-size: 1.8rem; line-height: 1.2; margin: 1rem 0 .6rem; overflow-wrap: anywhere; }++h2 { font-size: 1.3rem; margin: 1.8rem 0 .5rem; }++h3 { font-size: 1.08rem; margin: 1.2rem 0 .4rem; }++.muted { color: var(--text-muted, #7386a3); }++.small { font-size: .85rem; }++.crumbs { font-size: .9rem; color: var(--text-muted, #7386a3); margin: 8px 0; overflow-wrap: anywhere; }++code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .88em; }++code { overflow-wrap: anywhere; }++pre { background: var(--bg-secondary, #0f1729); border: 1px solid var(--border, #1f2d47); border-radius: 8px; padding: 10px 12px; overflow-x: auto; white-space: pre; }++pre code { overflow-wrap: normal; }++.table-wrap { overflow-x: auto; }++table { border-collapse: collapse; width: 100%; font-size: .92rem; }++th, td { text-align: left; vertical-align: top; padding: 7px 9px; border-bottom: 1px solid var(--border, #1f2d47); }++th { color: var(--text-secondary, #96a7c2); font-weight: 600; }++.badge { display: inline-block; font-size: .75rem; padding: 0 8px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); white-space: nowrap; line-height: 1.6; }++.badge.ok { border-color: var(--success, #22c55e); color: var(--success, #22c55e); }++.badge.info { border-color: var(--info, #38bdf8); color: var(--info, #38bdf8); }++.badge.warn { border-color: var(--warning, #f59e0b); color: var(--warning, #f59e0b); }++.badge.bad { border-color: var(--error, #ef4444); color: var(--error, #ef4444); }++.notice { border-left: 3px solid var(--info, #38bdf8); padding: 8px 12px; margin: 12px 0; background: var(--bg-card, #131c2e); border-radius: 0 8px 8px 0; }++.notice.ok { border-left-color: var(--success, #22c55e); }++.notice.warn { border-left-color: var(--warning, #f59e0b); }++.notice.bad { border-left-color: var(--error, #ef4444); }++button { font: inherit; cursor: pointer; }++form.inline { display: inline; }++.prose { max-width: 820px; overflow-wrap: anywhere; }++.prose table { margin: .6rem 0; }++.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }++++/* The home page's showcase sections (openvibe-shared/showcase.css) sit inside .page, which has its own gutter. */++.page .sc-hero, .page .sc-sec, .page .sc-cta { padding-left: 0; padding-right: 0; }++.page .sc-ic .ov-icon { --ovi-size: 28px !important; }++button.sc-btn { background: transparent; }++button.sc-btn.sc-primary { border: 0; }++.lede { color: var(--text-secondary, #96a7c2); max-width: 72ch; }++.stats { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }++.stats li { border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 8px 12px; background: var(--bg-card, #131c2e); color: var(--text-secondary, #96a7c2); }++.stats b { display: block; font-size: 1.15rem; color: var(--text-primary, #e6edf7); }++++/* ── Rarity: one hue per tier, shown as it is (ADR-054) ── */++.r-common { --rar: #94a3b8; }++.r-uncommon { --rar: #4ade80; }++.r-rare { --rar: #38bdf8; }++.r-epic { --rar: #c084fc; }++.r-legendary { --rar: #fbbf24; }++.inv-rarity { display: inline-block; font-size: .72rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--rar); border: 1px solid color-mix(in srgb, var(--rar) 55%, transparent); background: color-mix(in srgb, var(--rar) 12%, transparent); border-radius: 999px; padding: 0 8px; line-height: 1.7; white-space: nowrap; }++++/* ── The item tile: its emoji or image on a rarity glow ── */++.inv-art { display: inline-grid; place-items: center; flex: none; border-radius: 14px; line-height: 1; background: radial-gradient(circle at 50% 35%, color-mix(in srgb, var(--rar, #94a3b8) 30%, transparent), color-mix(in srgb, var(--rar, #94a3b8) 6%, var(--bg-secondary, #0f1729)) 70%); border: 1px solid color-mix(in srgb, var(--rar, #94a3b8) 40%, var(--border, #1f2d47)); }++.inv-art img { width: 100%; height: 100%; object-fit: contain; border-radius: inherit; }++.inv-art-md { width: 56px; height: 56px; font-size: 28px; }++.inv-art-lg { width: 64px; height: 64px; font-size: 32px; }++.inv-art-xl { width: 112px; height: 112px; font-size: 58px; border-radius: 22px; }++.r-legendary .inv-art, .inv-art.r-legendary { box-shadow: 0 0 0 1px color-mix(in srgb, var(--rar) 25%, transparent), 0 10px 30px -12px color-mix(in srgb, var(--rar) 70%, transparent); }++++/* ── Item cards ── */++.inv-grid { list-style: none; padding: 0; margin: 12px 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); }++.inv-card { display: flex; flex-direction: column; gap: 8px; border: 1px solid var(--border, #1f2d47); border-top: 3px solid color-mix(in srgb, var(--rar) 70%, transparent); border-radius: 12px; padding: 12px; background: var(--bg-card, #131c2e); min-width: 0; }++.inv-card-link { display: flex; gap: 12px; align-items: center; color: inherit; text-decoration: none; min-width: 0; }++.inv-card-link:hover .inv-card-name { text-decoration: underline; }++.inv-card-body { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; min-width: 0; }++.inv-card-name { font-weight: 650; overflow-wrap: anywhere; }++.inv-card-desc { margin: 0; font-size: .88rem; color: var(--text-secondary, #96a7c2); }++.inv-card-meta { margin: 0; font-size: .8rem; color: var(--text-muted, #7386a3); }++.inv-equip { display: flex; align-items: center; gap: 8px; margin-top: auto; }++.inv-equip .sc-btn { padding: 6px 14px; font-size: .88rem; }++.inv-worn { margin: 0; font-size: .8rem; font-weight: 700; color: var(--success, #22c55e); }++.inv-worn::before { content: "● "; }++++/* ── Kind tabs on /items ── */++.inv-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 14px 0 6px; }++.inv-tabs a { padding: 5px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-secondary, #96a7c2); text-decoration: none; font-size: .9rem; }++.inv-tabs a[aria-current="page"] { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }++.inv-kind h2 .small { font-weight: 400; }++++/* ── One item ── */++.inv-detail { display: flex; gap: 20px; align-items: center; flex-wrap: wrap; margin: 6px 0 4px; }++.inv-detail h1 { margin: 0 0 6px; }++++/* ── The home hero's panel: a few of the rarest items ── */++.inv-hero-panel { padding: 20px; border-radius: 18px; border: 1px solid var(--border, #1f2d47); background: linear-gradient(160deg, color-mix(in srgb, #fbbf24 8%, var(--bg-card, #131c2e)), var(--bg-card, #131c2e) 65%); box-shadow: 0 18px 40px -24px rgba(0, 0, 0, .7); }++.inv-hero-title { margin: 0 0 12px; font-weight: 750; font-size: 1rem; }++.inv-hero-grid { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }++.inv-hero-grid a { display: flex; flex-direction: column; align-items: center; gap: 6px; color: inherit; text-decoration: none; text-align: center; }++.inv-hero-grid a:hover .inv-hero-name { text-decoration: underline; }++.inv-hero-name { font-size: .78rem; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; line-height: 1.25; }++@media (max-width: 520px) { .inv-art-xl { width: 88px; height: 88px; font-size: 46px; } .inv-grid { grid-template-columns: 1fr; } }++.inv-btn { font: inherit; font-size: .88rem; font-weight: 650; padding: 6px 16px; border-radius: 10px; border: 1px solid var(--border-light, #2c3d5c); background: transparent; color: var(--text-primary, #e6edf7); }++.inv-btn:hover { border-color: var(--accent, #3b82f6); background: var(--bg-hover, #1c2a44); }++.inv-btn.primary { border-color: transparent; color: var(--on-accent, #fff); background: linear-gradient(135deg, var(--accent, #3b82f6), var(--accent-dark, #1d4ed8)); }++.inv-btn.primary:hover { filter: brightness(1.08); }++.inv-equip .inv-worn { margin-right: auto; }++.inv-card.is-worn { border-color: color-mix(in srgb, var(--success, #22c55e) 55%, var(--border, #1f2d47)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--success, #22c55e) 30%, transparent); }nnew file mode 100644@@@ -0,0 +1,58 @@++#!/usr/bin/env node++'use strict';++++/**++ * ADR-054 §8, convert and verify: Live's cosmetics become instances and equipped slots here++ * (server/inventory/import-live.js).++ *++ * node --env-file=/etc/openvibe/inventory.env scripts/import-live.js --live-env /etc/openvibe/live.env dry run++ * node --env-file=/etc/openvibe/inventory.env scripts/import-live.js --live-env /etc/openvibe/live.env --apply convert++ * node --env-file=/etc/openvibe/inventory.env scripts/import-live.js --live-env /etc/openvibe/live.env --verify compare++ *++ * Live's database is only read (SELECT). Its DATABASE_URL is taken from --live-env and never printed. The script prints++ * counts and subjects only. --verify exits 1 when anyone differs.++ */++const fs = require('fs');++const { createDb } = require('openvibe-sdk/db');++const { createServiceOutbox } = require('openvibe-sdk/events');++const configLib = require('../server/config');++const { openDb, createStore } = require('../server/db');++const { createInventory } = require('../server/inventory/store');++const { seed } = require('../server/inventory/seed');++const { importLive, verifyLive } = require('../server/inventory/import-live');++++const args = process.argv.slice(2);++const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };++const flag = (name) => args.includes(`--${name}`);++++function readEnvValue(file, key) {++ const line = fs.readFileSync(file, 'utf8').split('\n').find((l) => l.startsWith(`${key}=`));++ if (!line) throw new Error(`${key} is not in ${file}`);++ return line.slice(key.length + 1).trim().replace(/^(['"])(.*)\1$/, '$2');++}++++(async () => {++ const liveEnv = opt('live-env');++ if (!liveEnv) throw new Error('--live-env <Live\'s env file> is required (its DATABASE_URL is read, never printed)');++ const quiet = { log() {}, warn: (...a) => console.warn(...a), error: (...a) => console.error(...a) };++ const live = createDb({ url: readEnvValue(liveEnv, 'DATABASE_URL'), service: 'inventory-import-live', max: 1, log: quiet });++ const db = await openDb(configLib.load(), { log: quiet });++ const s = createStore(db);++ const inv = createInventory({ s, outbox: createServiceOutbox({ db, source: 'inventory', log: quiet }), log: quiet });++ await seed(inv, { log: quiet });++ try {++ if (flag('verify')) {++ const v = await verifyLive({ live, inv, db });++ for (const d of v.differences) console.log(` ${d.subject}: missing ${d.missing.join(',') || '-'}; slots differing ${d.slots.join(',') || '-'}`);++ if (v.unknown.length) console.log(` not in Live's catalog any more (not imported): ${v.unknown.join(', ')}`);++ console.log(v.differences.length ? `verify: ${v.differences.length} of ${v.people} people differ` : `verify: all ${v.people} people match (${v.unlocks} unlocks, ${v.equippedSlots} slots)`);++ process.exitCode = v.differences.length ? 1 : 0;++ return;++ }++ const r = await importLive({ live, inv, db, apply: flag('apply'), log: console });++ console.log(JSON.stringify({ apply: flag('apply'), ...r }));++ } finally {++ await live.close();++ await db.close();++ }++})().catch((err) => { console.error(`import-live failed: ${err.message}`); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,166 @@++'use strict';++++/**++ * OpenVibe.Inventory — Express app factory; server/index.js listens, tests build their own instance with a temp database++ * and a mock Network.++ *++ * /, /updates the pages (http/pages.js)++ * /api/v1/* the API (http/api.js)++ * /auth/* Network SSO with PKCE (auth/sso.js)++ * /api/health, /api/ready, /release.json, /metrics (loopback only)++ *++ * The product fills this in: its routes in http/api.js, its pages in http/pages.js, its capabilities in++ * http/principal.js and its budgets in http/caller-limits.js. The plumbing here (helmet, SSO, legal pages,++ * static assets, the API mount, the 404 and the error handler) stays.++ */++const path = require('path');++const express = require('express');++const helmet = require('helmet');++const cookieParser = require('cookie-parser');++const rateLimit = require('express-rate-limit');++const contracts = require('openvibe-contracts');++const cache = require('openvibe-shared/cache-policy');++++const configLib = require('./config');++const { openStore } = require('./db');++const { createKeyStore } = require('./auth/keys');++const { createSso } = require('./auth/sso');++const { createPrincipal } = require('./http/principal');++const { createApi } = require('./http/api');++const { createPageRoutes } = require('./http/pages');++const { createServiceReadiness } = require('./observability');++const { createCallerLimits } = require('./http/caller-limits');++const { createServiceOutbox } = require('openvibe-sdk/events');++const { createNetworkSender } = require('openvibe-sdk/account-data');++const { createInventory } = require('./inventory/store');++const { seed } = require('./inventory/seed');++const accountDataLib = require('./inventory/account-data');++const { assetVersion, send } = require('./render/layout');++const { html } = require('./render/html');++++const PUBLIC_DIR = path.join(__dirname, '..', 'public');++const VERSION = require('../package.json').version;++++/**++ * opts: config, store, now (clock), fetchImpl (Network), log, limitsNow, callerLimits (false: count nobody,++ * tests only), valkey, eventsFetch (the outbox relay's fetch, tests), accountSend (a stand-in for Network's internal++ * routes, tests)++ */++async function createApp(opts = {}) {++ const config = opts.config || configLib.load();++ const log = opts.log || console;++ const fetchImpl = opts.fetchImpl || globalThis.fetch;++ const s = opts.store || await openStore(config, { now: opts.now, log });++++ const keys = createKeyStore({ config, fetchImpl, log });++ const sso = createSso({ config, keys, fetchImpl, now: s.now, log });++ const principal = createPrincipal({ config, keys });++ // The inventory (ADR-054) and its events, written in the same transaction as each change.++ const outbox = createServiceOutbox({++ db: s.db, source: 'inventory', eventsUrl: config.events.url, networkInternalUrl: config.networkInternalUrl,++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, intervalMs: config.events.intervalMs,++ now: s.now, fetch: opts.eventsFetch || fetchImpl, log,++ });++ const inv = createInventory({ s, outbox, log });++ await seed(inv, { log });++ // Account export and deletion (ADR-033): the table map, and the sender to Network's internal routes with this++ // service's own client-credentials token.++ const accountData = accountDataLib.create({ db: s.db, log });++ const accountSend = opts.accountSend || (config.oauth.clientSecret++ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })++ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Inventory cannot answer account events'); });++ const ctx = { config, s, keys, sso, principal, log, outbox, inv, accountData };++++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', config.trustProxy);++ const release = require('openvibe-shared/release').createRelease({ service: 'inventory', root: path.join(__dirname, '..') });++ require('./render/layout').setRelease(release.release);++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'inventory', release: release.release });++ app.locals.metrics = metrics.registry;++ app.locals.ctx = ctx;++ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);++ ctx.valkey = valkey;++ ctx.limits = createCallerLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: opts.callerLimits !== false, valkey });++++ app.use(contracts.http.middleware());++ app.use(helmet({++ contentSecurityPolicy: {++ directives: {++ defaultSrc: ["'self'"],++ // The OpenVibe Frame (theme-loader, navbar, footer) comes from the Network; the inline init is ours.++ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],++ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],++ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],++ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],++ connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],++ frameSrc: ["'self'", 'https://openvibe.network'],++ frameAncestors: ["'none'"],++ objectSrc: ["'none'"],++ baseUri: ["'self'"],++ formAction: ["'self'", 'https://openvibe.network'],++ },++ },++ frameguard: { action: 'deny' },++ crossOriginEmbedderPolicy: false,++ crossOriginResourcePolicy: { policy: 'same-site' },++ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },++ }));++ app.use(cookieParser());++++ // ── OpenVibe.Events → Inventory (loopback only: nginx answers 404 for /internal/) ──++ // network.account.export_requested and network.account.deleted, answered by openvibe-sdk/account-data's consumer,++ // which reads the raw body itself (the v2 signature covers it) and refuses a request that came through a proxy.++ app.post('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));++++ // ── Machine endpoints ───────────────────────────────────++ app.get('/api/health', (_req, res, next) => outbox.status().then((events) => res.json({ status: 'ok', service: 'openvibe-inventory', version: VERSION, events }), next));++ release.mount(app, { registry: metrics.registry });++ const readiness = createServiceReadiness({ s, config, release: release.release, valkey });++ app.get('/api/ready', readiness.handler);++++ // ── Who is asking (verified offline; refreshed when expired) ──++ app.use(sso.middleware());++++ // ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──++ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));++ app.use('/auth', sso.routes());++ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'inventory', service: 'inventory', host: 'inventory.openvibe.network', name: 'OpenVibe.Inventory', profile: 'ugc' })); }++++ // ── Static assets (content-hashed ?v= → immutable) ──────++ app.use('/shared', require('openvibe-shared/serve').handler());++ app.use(express.static(PUBLIC_DIR, {++ index: false, redirect: false,++ setHeaders(res, filePath) {++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');++ const v = res.req && res.req.query && res.req.query.v;++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));++ },++ }));++++ // ── API ─────────────────────────────────────────────────++ app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.INVENTORY_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));++ app.use('/api/v1', createApi(ctx));++ app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));++++ // ── Pages ───────────────────────────────────────────────++ app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.INVENTORY_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));++ app.use(createPageRoutes(ctx));++ app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/">the home page</a> or <a href="/updates">the update log</a>.</p>` }));++++ // eslint-disable-next-line no-unused-vars++ app.use((err, req, res, _next) => {++ // Never log request bodies.++ log.error('[OpenVibe.Inventory]', err && err.message ? err.message.slice(0, 300) : err);++ if (res.headersSent) return;++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (err && err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });++ if (err && err.type === 'entity.too.large') return req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov }) : res.status(413).type('text/plain').send('That request was too large.');++ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });++ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');++ });++++ return { app, ctx };++}++++module.exports = { createApp };nnew file mode 100644@@@ -0,0 +1,113 @@++'use strict';++++/**++ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured++ * internal URL and kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline++ * against it: the person's session token (sign-in).++ *++ * The SDK keeps one client per URL: the last good keys through outages, exponential backoff, a++ * rotation honoured at once, unknown-kid floods throttled, the refresh timer unref'd. This module++ * is a thin shim so the rest of OpenVibe.Inventory (the readiness check, the SSO middleware) can keep talking to++ * `keys.get()` / `keys.ensure()` / `keys.loaded()`,++ * while verification goes through the SDK's verifyUserToken / verifyAppToken with `jwks: <url>`.++ *++ * Nothing the SDK says about a failed key fetch leaves this module: its message names the internal++ * JWKS URL and the fetch error, so token.no_key answers 'signing key not loaded yet' (and an error++ * without a code 'token does not verify'). Reasons about the token itself (audience, expiry,++ * signature) are kept. Every rejection is logged.++ */++const sdk = require('openvibe-sdk/auth');++++/** Public reasons. The SDK's own text (the JWKS URL, the fetch error) never goes over the wire. */++const KEY_UNAVAILABLE = 'signing key not loaded yet';++const DOES_NOT_VERIFY = 'token does not verify';++++/**++ * What a caller may be told. Only token.no_key carries the SDK's fetch text (the internal JWKS URL and the connect++ * error); every other code's message is about the token itself ('not for openvibe.media', 'expired'), which a++ * developer in the playground needs, so it is kept.++ */++function publicReason(err) {++ const code = err && err.code;++ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;++ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;++}++++/** Resolve the JWKS URL for the configured Network (internal first, then public). */++function jwksUrl(config) {++ return (config.networkInternalUrl || config.networkUrl) + '/api/.well-known/jwks';++}++++/**++ * `keys` is what app.js hands the rest of the service: a facade over the SDK's jwksClient. A synchronous caller++ * wants a PEM, so we mirror the cached first key as PEM — refreshed every time the SDK gives us keys (ensure(),++ * verifyUser(), verifyApp()).++ */++function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {++ const url = jwksUrl(config);++ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });++ let mirror = null; // { pem, count } — the first cached key as PEM++++ async function refreshMirror() {++ try {++ const ks = await client.keys();++ const first = ks && ks[0];++ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;++ } catch (err) { log.warn(`[OpenVibe.Inventory] JWKS mirror refresh failed: ${err && err.message || err}`); mirror = null; }++ }++ /** First fetch (or wait out the backoff window). Refreshes the mirror. */++ async function ensure() { await refreshMirror(); }++ /** Cached public key as PEM — for openvibe-contracts' verifyServiceToken (synchronous). */++ function get() { return mirror ? mirror.pem : null; }++ function loaded() { return Boolean(mirror); }++++ /** The SDK's `now` is a NUMBER of milliseconds (it does Math.floor(now / 1000)); a function disables every expiry check. */++ const asMs = (v) => (typeof v === 'function' ? v() : (typeof v === 'number' ? v : Date.now()));++++ // Verify a user (session) token via the SDK. Returns { ok, claims } | { ok: false, reason, code, expired }.++ // `reason` is one of the two fixed public strings; the SDK's text is logged, never returned.++ async function verifyUser(token, { issuer, audience, now = Date.now() } = {}) {++ try {++ const claims = await sdk.verifyUserToken(token, { jwks: url, issuer, audience, now: asMs(now), log });++ await refreshMirror();++ return { ok: true, claims };++ } catch (err) {++ await refreshMirror();++ const code = err && err.code;++ log.warn(`[OpenVibe.Inventory] user token rejected (${code || 'error'}):`, (err && err.message) || err);++ return { ok: false, reason: publicReason(err), code, expired: code === 'token.expired' };++ }++ }++ // Verify an app token. Same shape and same fixed reasons. The playground reads v.reason for its detail.++ async function verifyApp(token, { issuer, audience, acceptSandbox = true, now = Date.now() } = {}) {++ try {++ const claims = await sdk.verifyAppToken(token, { jwks: url, issuer, audience, acceptSandbox, now: asMs(now), log });++ await refreshMirror();++ return { ok: true, claims };++ } catch (err) {++ await refreshMirror();++ const code = err && err.code;++ log.warn(`[OpenVibe.Inventory] app token rejected (${code || 'error'}):`, (err && err.message) || err);++ return { ok: false, reason: publicReason(err), code };++ }++ }++++ /**++ * The PEM for the key a token's header names (a rotation publishes two keys: the first one is not always it), for++ * openvibe-contracts' synchronous guard. The SDK refetches for an unknown kid (at most every 30 s). Null when no++ * key is loaded; a token that names no kid gets the first key.++ */++ async function pemForToken(token) {++ let kid = null;++ try { kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { /* the guard refuses it */ }++ try {++ const ks = await client.keysForKid(kid);++ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];++ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;++ } catch { return null; }++ }++++ return { ensure, get, loaded, status: client.status, verifyUser, verifyApp, pemForToken, client };++}++++module.exports = { createKeyStore, jwksUrl };\\ No newline at end of filennew file mode 100644@@@ -0,0 +1,258 @@++'use strict';++++/**++ * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `inventory`.++ *++ * GET /auth/login → Network /oauth/authorize?…&code_challenge=…&code_challenge_method=S256++ * (?silent=1 adds prompt=none; ?next= a same-site path)++ * GET /auth/callback → state check, server-side code exchange (client secret + code_verifier)++ * GET /auth/logout → clear cookies, revoke the refresh token (best effort)++ * GET /auth/me → { user } for the shared navbar (it cannot read the httpOnly cookie); { user: null } for a guest++ *++ * The person's Network access token lives in an httpOnly cookie (inventory_at) no script can read: with it, a script++ * could act as them. The refresh token (inventory_rt) is httpOnly too. Neither is ever stored server-side, rendered++ * or logged.++ */++const crypto = require('crypto');++const express = require('express');++const cache = require('openvibe-shared/cache-policy');++++const ACCESS_COOKIE = 'inventory_at';++const REFRESH_COOKIE = 'inventory_rt';++const HINT_COOKIE = 'ov_sso_hint';++const FLOW_COOKIE = 'inventory_oauth';++++function pkcePair() {++ const verifier = crypto.randomBytes(32).toString('base64url'); // 43 characters++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');++ return { verifier, challenge };++}++++/** Same-site relative paths only (never protocol-relative, never another origin). */++function sanitizeNext(next) {++ // Browsers drop tab and newline characters from a URL and read a backslash as "/": "/<TAB>/evil.com" would++ // leave the site. A next with any control character or backslash goes home.++ if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';++ if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';++ return next;++}++++function authorizeUrl(config, { state, challenge, silent = false }) {++ const q = new URLSearchParams({++ response_type: 'code',++ client_id: config.oauth.clientId,++ redirect_uri: config.oauth.redirectUri,++ scope: config.oauth.scope,++ state,++ code_challenge: challenge,++ code_challenge_method: 'S256',++ });++ if (silent) q.set('prompt', 'none');++ return `${config.networkUrl}/oauth/authorize?${q.toString()}`;++}++++function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {++ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });++++ function setSession(res, data) {++ res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });++ if (data.refresh_token) res.cookie(REFRESH_COOKIE, data.refresh_token, { ...cookieBase(), maxAge: 30 * 24 * 60 * 60 * 1000 });++ res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });++ }++ function clearSession(res) {++ res.clearCookie(ACCESS_COOKIE, cookieBase());++ res.clearCookie(REFRESH_COOKIE, cookieBase());++ }++++ /** POST the Network token endpoint (internal URL). Throws { status, error } on refusal. */++ async function tokenGrant(fields) {++ const body = new URLSearchParams({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...fields });++ let res;++ try {++ res = await fetchImpl(`${config.networkInternalUrl}/oauth/token`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },++ body, signal: AbortSignal.timeout(10_000),++ });++ } catch (err) {++ throw Object.assign(new Error('OpenVibe.Network did not answer'), { status: 502, error: err && err.name === 'TimeoutError' ? 'timeout' : 'unreachable' });++ }++ const data = await res.json().catch(() => null);++ if (!res.ok || !data || !data.access_token) {++ throw Object.assign(new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`),++ { status: res.status, error: (data && data.error) || 'invalid_grant' });++ }++ return data;++ }++++ /**++ * A OpenVibe.Inventory session is a Network SESSION token: audience openvibe.network and a person. The Network++ * signs FedCM assertions (audience = any owned-zone origin that asked), app/service tokens and++ * internal tokens with the same key and issuer; none of them may become a session here.++ */++ async function verifySession(token) {++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });++ const c = v.claims;++ if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };++ return v;++ }++++ function viewerFromClaims(claims, token) {++ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;++ const role = typeof claims.role === 'string' ? claims.role : 'user';++ return {++ kind: 'user',++ subject,++ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,++ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (typeof claims.username === 'string' ? claims.username.slice(0, 64) : 'you'),++ role,++ // The person's Network access token: used for server-side Network calls, never rendered.++ token,++ };++ }++++ /** One refresh per request, however many Network calls in it see a 401 (refresh tokens rotate). */++ async function refresh(req, res) {++ if (!req.sessionRefresh) req.sessionRefresh = await doRefresh(req, res);++ return req.sessionRefresh;++ }++++ async function doRefresh(req, res) {++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];++ if (!rt) return null;++ try {++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });++ setSession(res, data);++ const v = await verifySession(data.access_token);++ return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;++ } catch (err) {++ if (err.status && err.status < 500) clearSession(res);++ return null;++ }++ }++++ /** req.viewer for every page: the signed-in person, refreshed when their token expired. */++ function middleware() {++ return async (req, res, next) => {++ req.viewer = { kind: 'anonymous' };++ const token = req.cookies && req.cookies[ACCESS_COOKIE];++ if (!token) return next();++ await keys.ensure();++ const v = await verifySession(token);++ if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }++ if (v.expired) {++ const fresh = await refresh(req, res);++ if (fresh) { req.viewer = fresh; return next(); }++ }++ if (keys.loaded()) clearSession(res);++ next();++ };++ }++++ /**++ * Run fn(token) as the viewer; when the Network answers 401 (expired or revoked session), refresh++ * once and run it again. Other failures are returned to the caller unchanged.++ */++ async function asViewer(req, res, fn) {++ try {++ return await fn(req.viewer.token);++ } catch (err) {++ if (!err || err.status !== 401) throw err;++ const fresh = await refresh(req, res);++ if (!fresh) throw err;++ req.viewer = fresh;++ return fn(fresh.token);++ }++ }++++ function routes() {++ const r = express.Router();++ const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });++++ r.get('/login', (req, res) => {++ const silent = Boolean(req.query.silent) && req.query.silent !== '0';++ const state = crypto.randomBytes(16).toString('hex');++ const { verifier, challenge } = pkcePair();++ res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next), silent }), flowOpts());++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ res.redirect(authorizeUrl(config, { state, challenge, silent }));++ });++++ r.get('/callback', async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ let flow = null;++ try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }++ res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });++ const next = flow ? sanitizeNext(flow.next) : '/';++ const { code, state, error } = req.query;++ if (error) {++ if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);++ return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);++ }++ if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'++ || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {++ return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');++ }++ if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');++ try {++ const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: config.oauth.redirectUri, code_verifier: flow.verifier });++ setSession(res, data);++ return res.redirect(next);++ } catch (err) {++ log.warn('[OpenVibe.Inventory] sign-in exchange failed:', err.status || '', err.error || '');++ return res.status(err.status && err.status < 500 ? 400 : 502).type('text/plain')++ .send(err.status && err.status < 500 ? 'The Network refused the sign-in code. Please sign in again.' : 'Sign-in failed: OpenVibe.Network did not answer. Please try again.');++ }++ });++++ // Sign-out ends the session, so another site must not be able to trigger it (a cross-site++ // <img src=/auth/logout> or link). A same-origin navigation or form, or the address bar++ // (Sec-Fetch-Site none), signs out directly; anything else gets a confirm button (POST).++ const sameOriginRequest = (req) => {++ const site = String(req.get('sec-fetch-site') || '');++ if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');++ const origin = req.get('origin');++ if (origin) return origin === `${req.protocol}://${req.get('host')}`;++ return req.method === 'GET'; // no fetch metadata and no Origin: an old browser navigating++ };++ r.get('/logout', (req, res, next) => {++ if (sameOriginRequest(req)) return next();++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next))}` : '';++ res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`++ + `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`++ + `<p>Sign out of OpenVibe.Inventory?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);++ });++ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));++ r.all('/logout', async (req, res) => {++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];++ if (rt) {++ fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {++ method: 'POST', headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: rt }),++ signal: AbortSignal.timeout(3000),++ }).catch(() => {});++ }++ clearSession(res);++ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });++ res.redirect(303, sanitizeNext(req.query.next));++ });++++ r.get('/me', (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ // No session at all (a guest: no access or refresh cookie) is signed out, not an error: the shared++ // navbar asks this on every page view, and a 401 logged a console error on each (browser check,++ // OpenVibe.Host). A session cookie that is present but invalid or expired still answers 401.++ const c = req.cookies || {};++ if (!c[ACCESS_COOKIE] && !c[REFRESH_COOKIE] && !req.get('authorization')) return res.json({ user: null });++ const v = req.viewer;++ if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });++ res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role } });++ });++ return r;++ }++++ return { middleware, routes, asViewer, tokenGrant, pkcePair };++}++++module.exports = { createSso, pkcePair, sanitizeNext, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++++/**++ * OpenVibe.Inventory configuration. Every value comes from the environment (production: /etc/openvibe/inventory.env, see++ * .env.example). Only environment variable NAMES appear in code and docs; secrets are never logged.++ *++ * load(env) is pure so tests can build a config without touching process.env.++ */++require('dotenv').config();++const trim = (s) => String(s || '').replace(/\/+$/, '');++const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);++++function load(env = process.env) {++ const nodeEnv = env.NODE_ENV || 'development';++ const isProduction = nodeEnv === 'production';++ const port = int(env.PORT, 5030);++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://inventory.openvibe.network' : `http://localhost:${port}`));++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');++++ return {++ service: 'inventory',++ port,++ host: env.HOST || '127.0.0.1',++ nodeEnv,++ isProduction,++ baseUrl,++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,++ // Per-caller limits (server/http/caller-limits.js): the requests one caller (an app, a person, else an++ // address) may make per minute and per hour. The product's own routes add tighter budgets there.++ limits: {++ minute: Math.max(1, int(env.INVENTORY_LIMITS_MINUTE, 120)),++ hour: Math.max(1, int(env.INVENTORY_LIMITS_HOUR, 3000)),++ },++++ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role). In++ // development without DATABASE_URL an embedded PGlite database in data/pglite is used (INVENTORY_PGLITE_DIR++ // overrides the directory).++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.INVENTORY_PGLITE_DIR || '' },++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:inventory:' },++++ // OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.++ networkUrl,++ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),++ networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),++ // The audience this service's app, agent and service tokens carry.++ audience: env.INVENTORY_AUDIENCE || 'inventory.openvibe.network',++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'inventory',++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,++ scope: 'profile',++ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',++ },++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },++++ // OpenVibe.Events: the outbox relay publishes inventory.item.* and inventory.definition.published when EVENTS_URL++ // and the client secret are set (otherwise events wait in event_outbox). INVENTORY_EVENTS_SECRET signs the++ // deliveries to POST /internal/events (account export and deletion, ADR-033; comma-separated for rotation, 32+++ // characters each); unset, the route answers 503 and no subscription is created at boot.++ events: {++ url: trim(env.EVENTS_URL || ''),++ intervalMs: Math.max(50, int(env.EVENTS_RELAY_INTERVAL_MS, 2000)),++ secrets: String(env.INVENTORY_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),++ },++ };++}++++module.exports = { load };nnew file mode 100644@@@ -0,0 +1,211 @@++{++ "_about": "The kinds OpenVibe.Inventory registers at boot (ADR-054 §8). Live's cosmetics are the first.",++ "kinds": [++ {++ "id": "live.name_effect",++ "name": "Name effect",++ "description": "How your name looks in chat, on overlays and on your profile.",++ "slots": [++ "name_effect"++ ],++ "surfaces": [++ {++ "surface": "chat",++ "renderer": "live.name_effect.css@1"++ },++ {++ "surface": "overlay",++ "renderer": "live.name_effect.css@1"++ },++ {++ "surface": "profile",++ "renderer": "live.name_effect.css@1"++ }++ ],++ "attribute_schema": {++ "type": "object",++ "required": [++ "tier"++ ],++ "properties": {++ "tier": {++ "type": "integer",++ "minimum": 0,++ "maximum": 6++ },++ "css_class": {++ "type": "string",++ "pattern": "^[a-z0-9-]{1,40}$"++ }++ }++ },++ "stackable": false,++ "issuer": "service:live"++ },++ {++ "id": "live.particle",++ "name": "Particle effect",++ "description": "Particles that drift around your name and messages.",++ "slots": [++ "particle"++ ],++ "surfaces": [++ {++ "surface": "chat",++ "renderer": "live.particle.css@1"++ },++ {++ "surface": "overlay",++ "renderer": "live.particle.css@1"++ },++ {++ "surface": "profile",++ "renderer": "live.particle.css@1"++ }++ ],++ "attribute_schema": {++ "type": "object",++ "required": [++ "tier"++ ],++ "properties": {++ "tier": {++ "type": "integer",++ "minimum": 0,++ "maximum": 6++ },++ "css_class": {++ "type": "string",++ "pattern": "^[a-z0-9-]{1,40}$"++ },++ "chars": {++ "type": "string",++ "maxLength": 16++ }++ }++ },++ "stackable": false,++ "issuer": "service:live"++ },++ {++ "id": "live.hat",++ "name": "Hat",++ "description": "A hat on your avatar and next to your name.",++ "slots": [++ "hat"++ ],++ "surfaces": [++ {++ "surface": "chat",++ "renderer": "live.hat.glyph@1"++ },++ {++ "surface": "overlay",++ "renderer": "live.hat.glyph@1"++ },++ {++ "surface": "profile",++ "renderer": "live.hat.glyph@1"++ }++ ],++ "attribute_schema": {++ "type": "object",++ "required": [++ "tier"++ ],++ "properties": {++ "tier": {++ "type": "integer",++ "minimum": 0,++ "maximum": 6++ },++ "hat_char": {++ "type": "string",++ "maxLength": 8++ },++ "animated": {++ "enum": [++ "float",++ "pulse",++ "warp"++ ]++ }++ }++ },++ "stackable": false,++ "issuer": "service:live"++ },++ {++ "id": "live.voice",++ "name": "Voice",++ "description": "The voice your text-to-speech messages are read in.",++ "slots": [++ "voice"++ ],++ "surfaces": [++ {++ "surface": "chat",++ "renderer": "live.voice.speech@1"++ },++ {++ "surface": "overlay",++ "renderer": "live.voice.speech@1"++ }++ ],++ "attribute_schema": {++ "type": "object",++ "required": [++ "tier"++ ],++ "properties": {++ "tier": {++ "type": "integer",++ "minimum": 0,++ "maximum": 6++ },++ "pitch": {++ "type": "number",++ "minimum": 0,++ "maximum": 2++ },++ "rate": {++ "type": "number",++ "minimum": 0.1,++ "maximum": 3++ }++ }++ },++ "stackable": false,++ "issuer": "service:live"++ },++ {++ "id": "live.chat_tag",++ "name": "Chat tag",++ "description": "A short tag shown before your name in chat.",++ "slots": [++ "chat_tag"++ ],++ "surfaces": [++ {++ "surface": "chat",++ "renderer": "live.chat_tag.text@1"++ },++ {++ "surface": "profile",++ "renderer": "live.chat_tag.text@1"++ }++ ],++ "attribute_schema": {++ "type": "object",++ "properties": {++ "text": {++ "type": "string",++ "maxLength": 24++ }++ }++ },++ "stackable": false,++ "issuer": "service:live"++ }++ ]++}nnew file mode 100644@@@ -0,0 +1,1056 @@++{++ "_about": "Live's cosmetics catalog (OpenVibe.Live server/monetization/cosmetics.js COSMETICS, 2026-10-09) as definitions issued by service:live; alias is Live's item id. Tier maps to rarity: 0 and 1 common, 2 uncommon, 3 rare, 4 epic, 5 and 6 legendary.",++ "definitions": [++ {++ "alias": "fx_rainbow",++ "kind": "live.name_effect",++ "name": "Rainbow Name",++ "description": "Rainbow cycling colors",++ "art": {++ "emoji": "🌈",++ "token": "name-fx-rainbow"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "css_class": "name-fx-rainbow"++ }++ },++ {++ "alias": "fx_fire",++ "kind": "live.name_effect",++ "name": "Fire Name",++ "description": "Blazing flames",++ "art": {++ "emoji": "🔥",++ "token": "name-fx-fire"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "css_class": "name-fx-fire"++ }++ },++ {++ "alias": "fx_ice",++ "kind": "live.name_effect",++ "name": "Ice Name",++ "description": "Frosty glow",++ "art": {++ "emoji": "❄️",++ "token": "name-fx-ice"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "css_class": "name-fx-ice"++ }++ },++ {++ "alias": "fx_golden",++ "kind": "live.name_effect",++ "name": "Golden Name",++ "description": "Golden shine",++ "art": {++ "emoji": "👑",++ "token": "name-fx-golden"++ },++ "rarity": "uncommon",++ "attributes": {++ "tier": 2,++ "css_class": "name-fx-golden"++ }++ },++ {++ "alias": "fx_neon",++ "kind": "live.name_effect",++ "name": "Neon Name",++ "description": "Neon pulse",++ "art": {++ "emoji": "💡",++ "token": "name-fx-neon"++ },++ "rarity": "uncommon",++ "attributes": {++ "tier": 2,++ "css_class": "name-fx-neon"++ }++ },++ {++ "alias": "fx_galaxy",++ "kind": "live.name_effect",++ "name": "Galaxy Name",++ "description": "Cosmic swirl",++ "art": {++ "emoji": "🌌",++ "token": "name-fx-galaxy"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "css_class": "name-fx-galaxy"++ }++ },++ {++ "alias": "fx_void",++ "kind": "live.name_effect",++ "name": "Void Name",++ "description": "Warps spacetime",++ "art": {++ "emoji": "🕳️",++ "token": "name-fx-void"++ },++ "rarity": "epic",++ "attributes": {++ "tier": 4,++ "css_class": "name-fx-void"++ }++ },++ {++ "alias": "fx_toxic",++ "kind": "live.name_effect",++ "name": "Toxic Name",++ "description": "Toxic drip",++ "art": {++ "emoji": "☠️",++ "token": "name-fx-toxic"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "css_class": "name-fx-toxic"++ }++ },++ {++ "alias": "fx_blood",++ "kind": "live.name_effect",++ "name": "Blood Name",++ "description": "Dripping blood",++ "art": {++ "emoji": "🩸",++ "token": "name-fx-blood"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "css_class": "name-fx-blood"++ }++ },++ {++ "alias": "fx_shadow",++ "kind": "live.name_effect",++ "name": "Shadow Name",++ "description": "Dark shadow",++ "art": {++ "emoji": "🌑",++ "token": "name-fx-shadow"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "css_class": "name-fx-shadow"++ }++ },++ {++ "alias": "fx_glitch",++ "kind": "live.name_effect",++ "name": "Glitch Name",++ "description": "Digital glitch",++ "art": {++ "emoji": "📟",++ "token": "name-fx-glitch"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "css_class": "name-fx-glitch"++ }++ },++ {++ "alias": "fx_hologram",++ "kind": "live.name_effect",++ "name": "Hologram Name",++ "description": "Holographic shimmer",++ "art": {++ "emoji": "🔮",++ "token": "name-fx-hologram"++ },++ "rarity": "epic",++ "attributes": {++ "tier": 4,++ "css_class": "name-fx-hologram"++ }++ },++ {++ "alias": "fx_divine",++ "kind": "live.name_effect",++ "name": "Divine Name",++ "description": "Divine radiance",++ "art": {++ "emoji": "✝️",++ "token": "name-fx-divine"++ },++ "rarity": "legendary",++ "attributes": {++ "tier": 5,++ "css_class": "name-fx-divine"++ }++ },++ {++ "alias": "px_sparkle",++ "kind": "live.particle",++ "name": "Sparkle",++ "description": "Sparkle particles",++ "art": {++ "emoji": "✨",++ "token": "px-sparkle"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "css_class": "px-sparkle",++ "chars": "✦✧⋆"++ }++ },++ {++ "alias": "px_hearts",++ "kind": "live.particle",++ "name": "Hearts",++ "description": "Heart particles",++ "art": {++ "emoji": "💖",++ "token": "px-hearts"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "css_class": "px-hearts",++ "chars": "♥♡❤"++ }++ },++ {++ "alias": "px_flames",++ "kind": "live.particle",++ "name": "Flames",++ "description": "Flame embers",++ "art": {++ "emoji": "🔥",++ "token": "px-flames"++ },++ "rarity": "uncommon",++ "attributes": {++ "tier": 2,++ "css_class": "px-flames",++ "chars": "🔥🔸⚡"++ }++ },++ {++ "alias": "px_stars",++ "kind": "live.particle",++ "name": "Stars",++ "description": "Orbiting stars",++ "art": {++ "emoji": "⭐",++ "token": "px-stars"++ },++ "rarity": "uncommon",++ "attributes": {++ "tier": 2,++ "css_class": "px-stars",++ "chars": "★☆✩"++ }++ },++ {++ "alias": "px_void",++ "kind": "live.particle",++ "name": "Void",++ "description": "Dark matter",++ "art": {++ "emoji": "🕳️",++ "token": "px-void"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "css_class": "px-void",++ "chars": "◉◎⊙"++ }++ },++ {++ "alias": "hat_basic_cap",++ "kind": "live.hat",++ "name": "Basic Cap",++ "description": "Simple cap",++ "art": {++ "emoji": "🧢"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "hat_char": "🧢"++ }++ },++ {++ "alias": "hat_cowboy",++ "kind": "live.hat",++ "name": "Cowboy Hat",++ "description": "Yeehaw",++ "art": {++ "emoji": "🤠"++ },++ "rarity": "uncommon",++ "attributes": {++ "tier": 2,++ "hat_char": "🤠"++ }++ },++ {++ "alias": "hat_wizard",++ "kind": "live.hat",++ "name": "Wizard Hat",++ "description": "Magical",++ "art": {++ "emoji": "🧙"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "hat_char": "🧙",++ "animated": "float"++ }++ },++ {++ "alias": "hat_crown",++ "kind": "live.hat",++ "name": "Royal Crown",++ "description": "Royalty",++ "art": {++ "emoji": "👑"++ },++ "rarity": "epic",++ "attributes": {++ "tier": 4,++ "hat_char": "👑",++ "animated": "pulse"++ }++ },++ {++ "alias": "hat_halo",++ "kind": "live.hat",++ "name": "Halo",++ "description": "Angelic",++ "art": {++ "emoji": "😇"++ },++ "rarity": "legendary",++ "attributes": {++ "tier": 5,++ "hat_char": "😇",++ "animated": "float"++ }++ },++ {++ "alias": "hat_void_crown",++ "kind": "live.hat",++ "name": "Void Crown",++ "description": "From the dungeon depths",++ "art": {++ "emoji": "🕳️"++ },++ "rarity": "legendary",++ "attributes": {++ "tier": 6,++ "hat_char": "🕳️",++ "animated": "warp"++ }++ },++ {++ "alias": "voice_default",++ "kind": "live.voice",++ "name": "Default Voice",++ "description": "Standard TTS",++ "art": {++ "emoji": "🔊"++ },++ "rarity": "common",++ "attributes": {++ "tier": 0++ }++ },++ {++ "alias": "voice_deep",++ "kind": "live.voice",++ "name": "Deep Voice",++ "description": "Low and rumbly",++ "art": {++ "emoji": "🎵"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "pitch": 0.6,++ "rate": 0.9++ }++ },++ {++ "alias": "voice_chipmunk",++ "kind": "live.voice",++ "name": "Chipmunk Voice",++ "description": "Squeaky and fast",++ "art": {++ "emoji": "🐿️"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "pitch": 1.8,++ "rate": 1.3++ }++ },++ {++ "alias": "voice_robot",++ "kind": "live.voice",++ "name": "Robot Voice",++ "description": "Monotone machine",++ "art": {++ "emoji": "🤖"++ },++ "rarity": "uncommon",++ "attributes": {++ "tier": 2,++ "pitch": 0.8,++ "rate": 1++ }++ },++ {++ "alias": "voice_whisper",++ "kind": "live.voice",++ "name": "Whisper Voice",++ "description": "Quiet and eerie",++ "art": {++ "emoji": "🤫"++ },++ "rarity": "uncommon",++ "attributes": {++ "tier": 2,++ "pitch": 1.1,++ "rate": 0.7++ }++ },++ {++ "alias": "voice_demon",++ "kind": "live.voice",++ "name": "Demon Voice",++ "description": "From the underworld",++ "art": {++ "emoji": "😈"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "pitch": 0.3,++ "rate": 0.6++ }++ },++ {++ "alias": "gary",++ "kind": "live.voice",++ "name": "Gary",++ "description": "Standard voice",++ "art": {++ "emoji": "🔊"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "pitch": 1,++ "rate": 1++ }++ },++ {++ "alias": "brenda",++ "kind": "live.voice",++ "name": "Brenda",++ "description": "Friendly female",++ "art": {++ "emoji": "👩"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "pitch": 1.2,++ "rate": 1++ }++ },++ {++ "alias": "chadbot",++ "kind": "live.voice",++ "name": "ChadBot",++ "description": "Deep bro voice",++ "art": {++ "emoji": "💪"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "pitch": 0.5,++ "rate": 0.8++ }++ },++ {++ "alias": "karen",++ "kind": "live.voice",++ "name": "Karen",++ "description": "Manager-seeking",++ "art": {++ "emoji": "💅"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "pitch": 1.3,++ "rate": 1.1++ }++ },++ {++ "alias": "squeakmaster",++ "kind": "live.voice",++ "name": "SqueakMaster",++ "description": "Ultra-squeaky",++ "art": {++ "emoji": "🐭"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "pitch": 1.9,++ "rate": 1.4++ }++ },++ {++ "alias": "bigchungus",++ "kind": "live.voice",++ "name": "BigChungus",++ "description": "Absolute unit",++ "art": {++ "emoji": "🐰"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "pitch": 0.3,++ "rate": 0.7++ }++ },++ {++ "alias": "tweaker",++ "kind": "live.voice",++ "name": "Tweaker",++ "description": "Fast & nervous",++ "art": {++ "emoji": "⚡"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "pitch": 1.4,++ "rate": 1.8++ }++ },++ {++ "alias": "grandpa",++ "kind": "live.voice",++ "name": "Grandpa",++ "description": "Old & wise",++ "art": {++ "emoji": "👴"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "pitch": 0.7,++ "rate": 0.7++ }++ },++ {++ "alias": "crackhead",++ "kind": "live.voice",++ "name": "CrackheadCarl",++ "description": "Manic energy",++ "art": {++ "emoji": "💊"++ },++ "rarity": "uncommon",++ "attributes": {++ "tier": 2,++ "pitch": 1.5,++ "rate": 1.6++ }++ },++ {++ "alias": "ghostgirl",++ "kind": "live.voice",++ "name": "GhostGirl",++ "description": "Eerie whisper",++ "art": {++ "emoji": "👻"++ },++ "rarity": "uncommon",++ "attributes": {++ "tier": 2,++ "pitch": 1.6,++ "rate": 0.8++ }++ },++ {++ "alias": "robotoverlord",++ "kind": "live.voice",++ "name": "RobotOverlord",++ "description": "Machine overlord",++ "art": {++ "emoji": "🤖"++ },++ "rarity": "uncommon",++ "attributes": {++ "tier": 2,++ "pitch": 0.4,++ "rate": 0.9++ }++ },++ {++ "alias": "sassybitch",++ "kind": "live.voice",++ "name": "SassyBitch",++ "description": "Sassy attitude",++ "art": {++ "emoji": "💁"++ },++ "rarity": "uncommon",++ "attributes": {++ "tier": 2,++ "pitch": 1.3,++ "rate": 1.2++ }++ },++ {++ "alias": "demon",++ "kind": "live.voice",++ "name": "Demon (Legacy)",++ "description": "Demonic (espeak)",++ "art": {++ "emoji": "👹"++ },++ "rarity": "uncommon",++ "attributes": {++ "tier": 2,++ "pitch": 0.2,++ "rate": 0.5++ }++ },++ {++ "alias": "helium",++ "kind": "live.voice",++ "name": "Helium",++ "description": "Squeaky helium",++ "art": {++ "emoji": "🎈"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "pitch": 2,++ "rate": 1.5++ }++ },++ {++ "alias": "britbong",++ "kind": "live.voice",++ "name": "BritBong",++ "description": "British accent",++ "art": {++ "emoji": "🇬🇧"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "pitch": 1,++ "rate": 0.9++ }++ },++ {++ "alias": "yeehaw",++ "kind": "live.voice",++ "name": "YeeHaw",++ "description": "Southern drawl",++ "art": {++ "emoji": "🤠"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "pitch": 0.8,++ "rate": 0.9++ }++ },++ {++ "alias": "nyc",++ "kind": "live.voice",++ "name": "NYC",++ "description": "New York accent",++ "art": {++ "emoji": "🗽"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "pitch": 1.1,++ "rate": 1.3++ }++ },++ {++ "alias": "french",++ "kind": "live.voice",++ "name": "French",++ "description": "French accent",++ "art": {++ "emoji": "🇫🇷"++ },++ "rarity": "common",++ "attributes": {++ "tier": 1,++ "pitch": 1.1,++ "rate": 0.8++ }++ },++ {++ "alias": "chatterbox",++ "kind": "live.voice",++ "name": "Chatterbox",++ "description": "Achievement voice",++ "art": {++ "emoji": "💬"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "pitch": 1.2,++ "rate": 1.4++ }++ },++ {++ "alias": "fisherman",++ "kind": "live.voice",++ "name": "Fisherman",++ "description": "Achievement voice",++ "art": {++ "emoji": "🎣"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "pitch": 0.9,++ "rate": 0.85++ }++ },++ {++ "alias": "gc_smooth_operator",++ "kind": "live.voice",++ "name": "Smooth Operator",++ "description": "Google Cloud",++ "art": {++ "emoji": "🎤"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "pitch": 0.9,++ "rate": 0.95++ }++ },++ {++ "alias": "gc_silicon_sally",++ "kind": "live.voice",++ "name": "Silicon Sally",++ "description": "Google Cloud",++ "art": {++ "emoji": "🎤"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "pitch": 1.2,++ "rate": 1++ }++ },++ {++ "alias": "gc_brit_butler",++ "kind": "live.voice",++ "name": "British Butler",++ "description": "Google Cloud",++ "art": {++ "emoji": "🎤"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "pitch": 0.85,++ "rate": 0.9++ }++ },++ {++ "alias": "gc_lady_london",++ "kind": "live.voice",++ "name": "Lady London",++ "description": "Google Cloud",++ "art": {++ "emoji": "🎤"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "pitch": 1.15,++ "rate": 0.95++ }++ },++ {++ "alias": "gc_mumbai_mike",++ "kind": "live.voice",++ "name": "Mumbai Mike",++ "description": "Google Cloud",++ "art": {++ "emoji": "🎤"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "pitch": 1,++ "rate": 1.05++ }++ },++ {++ "alias": "gc_down_under",++ "kind": "live.voice",++ "name": "Down Under",++ "description": "Google Cloud",++ "art": {++ "emoji": "🎤"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "pitch": 0.95,++ "rate": 1++ }++ },++ {++ "alias": "gc_sheila",++ "kind": "live.voice",++ "name": "Sheila",++ "description": "Google Cloud",++ "art": {++ "emoji": "🎤"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "pitch": 1.25,++ "rate": 1.05++ }++ },++ {++ "alias": "gc_studio_f",++ "kind": "live.voice",++ "name": "Studio Female",++ "description": "Google Studio",++ "art": {++ "emoji": "🎙️"++ },++ "rarity": "epic",++ "attributes": {++ "tier": 4,++ "pitch": 1.1,++ "rate": 0.9++ }++ },++ {++ "alias": "gc_studio_m",++ "kind": "live.voice",++ "name": "Studio Male",++ "description": "Google Studio",++ "art": {++ "emoji": "🎙️"++ },++ "rarity": "epic",++ "attributes": {++ "tier": 4,++ "pitch": 0.85,++ "rate": 0.9++ }++ },++ {++ "alias": "pl_joanna_std",++ "kind": "live.voice",++ "name": "Joanna",++ "description": "Amazon Polly",++ "art": {++ "emoji": "🎤"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "pitch": 1.1,++ "rate": 1++ }++ },++ {++ "alias": "pl_matthew_std",++ "kind": "live.voice",++ "name": "Matthew",++ "description": "Amazon Polly",++ "art": {++ "emoji": "🎤"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "pitch": 0.9,++ "rate": 1++ }++ },++ {++ "alias": "pl_amy_std",++ "kind": "live.voice",++ "name": "Amy",++ "description": "Amazon Polly",++ "art": {++ "emoji": "🎤"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "pitch": 1.15,++ "rate": 1++ }++ },++ {++ "alias": "pl_brian_std",++ "kind": "live.voice",++ "name": "Brian",++ "description": "Amazon Polly",++ "art": {++ "emoji": "🎤"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "pitch": 0.85,++ "rate": 0.95++ }++ },++ {++ "alias": "pl_olivia_std",++ "kind": "live.voice",++ "name": "Olivia",++ "description": "Amazon Polly",++ "art": {++ "emoji": "🎤"++ },++ "rarity": "rare",++ "attributes": {++ "tier": 3,++ "pitch": 1.2,++ "rate": 1++ }++ },++ {++ "alias": "pl_danielle_long",++ "kind": "live.voice",++ "name": "Danielle",++ "description": "Polly Long-form",++ "art": {++ "emoji": "🎤"++ },++ "rarity": "epic",++ "attributes": {++ "tier": 4,++ "pitch": 1.1,++ "rate": 0.85++ }++ },++ {++ "alias": "pl_gregory_long",++ "kind": "live.voice",++ "name": "Gregory",++ "description": "Polly Long-form",++ "art": {++ "emoji": "🎤"++ },++ "rarity": "epic",++ "attributes": {++ "tier": 4,++ "pitch": 0.8,++ "rate": 0.85++ }++ },++ {++ "alias": "pl_gregory_neural",++ "kind": "live.voice",++ "name": "Gregory Neural",++ "description": "Polly Neural",++ "art": {++ "emoji": "🧠"++ },++ "rarity": "epic",++ "attributes": {++ "tier": 4,++ "pitch": 0.85,++ "rate": 0.9++ }++ },++ {++ "alias": "pl_ruth_long",++ "kind": "live.voice",++ "name": "Ruth",++ "description": "Polly Long-form",++ "art": {++ "emoji": "🎤"++ },++ "rarity": "epic",++ "attributes": {++ "tier": 4,++ "pitch": 1,++ "rate": 0.85++ }++ },++ {++ "alias": "pl_arthur_neural",++ "kind": "live.voice",++ "name": "Arthur Neural",++ "description": "Polly Neural",++ "art": {++ "emoji": "🧠"++ },++ "rarity": "legendary",++ "attributes": {++ "tier": 5,++ "pitch": 0.9,++ "rate": 0.85++ }++ },++ {++ "alias": "vs_mine_dwarf_lord",++ "kind": "live.voice",++ "name": "Dwarf Lord",++ "description": "Skill mastery",++ "art": {++ "emoji": "⛏️"++ },++ "rarity": "legendary",++ "attributes": {++ "tier": 5,++ "pitch": 0.5,++ "rate": 0.7++ }++ }++ ]++}nnew file mode 100644@@@ -0,0 +1,47 @@++'use strict';++++/**++ * OpenVibe.Inventory's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot. Nothing++ * written here is ever a secret, token or credential.++ */++const fs = require('fs');++const path = require('path');++const { createDb } = require('openvibe-sdk/db');++const { ids } = require('openvibe-contracts');++++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');++++async function openDb(config, { log = console, registry } = {}) {++ if (!config.db.url) {++ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh inventory)');++ const dir = config.db.pgliteDir || DEV_PGLITE;++ log.warn(`[OpenVibe.Inventory] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);++ fs.mkdirSync(dir, { recursive: true });++ const db = createDb({ pglite: dir, service: 'inventory', registry, log });++ await db.migrate({ dir: MIGRATIONS, log });++ return db;++ }++ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');++ const owner = createDb({ url: config.db.directUrl, service: 'inventory-migrate', max: 1, log });++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++ return createDb({ url: config.db.url, service: 'inventory', registry, log });++}++++/** The store every module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */++function createStore(db, { now = () => Date.now() } = {}) {++ return {++ db,++ now,++ iso: () => new Date(now()).toISOString(),++ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,++ tx: async (fn) => await db.tx(() => fn()),++ close: () => db.close(),++ };++}++++async function openStore(config, { now, log } = {}) {++ return createStore(await openDb(config, { log }), { now });++}++++module.exports = { openDb, openStore, createStore, MIGRATIONS };nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++++/**++ * /api/v1 — OpenVibe.Inventory's API (ADR-054; the capabilities are openvibe-contracts manifests/capabilities/inventory.*).++ *++ * GET /ping public liveness++ * GET /kinds, /kinds/:id inventory.item.read the kinds (inventory.kind@1)++ * GET /definitions?kind=&issuer= inventory.item.read published definitions (inventory.definition@1)++ * GET /definitions/:id inventory.item.read one definition (any status, for its issuer)++ * GET /people/:subject/items?kind=&cursor= inventory.item.read a person's public inventory (inventory.inventory@1)++ * GET /people/:subject/equipped inventory.item.read their equipped set (inventory.equipped@1)++ * GET /equipped?subjects=a,b,… inventory.item.read up to 100 equipped sets (inventory.equipped-batch@1)++ * GET /me/items inventory.item.list the caller's own inventory, every state++ * PUT /me/equipped inventory.equip.manage equip or clear a slot (inventory.equip-request@1)++ * POST /grants inventory.item.grant an issuer grants an instance (inventory.grant-request@1)++ * POST /instances/:id/consume | /revoke inventory.item.consume an issuer uses up or takes back an instance++ * POST /definitions, PATCH /definitions/:id inventory.definition.manage an issuer's definitions++ *++ * A person acts for themself (their token or this site's session). A service or app acting for a person sends that++ * person's subject in X-OV-Subject and holds the route's capability. Nothing here sells, buys, trades or converts an++ * item (ADR-054 §5). A refusal is an RFC 9457 problem+json with a stable code.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { asyncRouter } = require('./router');++const { InventoryError, SUBJECT_RE } = require('../inventory/store');++++function createApi(ctx) {++ const { config, principal, limits, inv } = ctx;++ const r = asyncRouter();++ const read = principal.requireRead('inventory.item.read');++++ r.use(express.json({ limit: '64kb' }));++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++ r.use(principal.middleware);++++ /** The person a request acts for: a person's own subject, or a service's X-OV-Subject. */++ function personOf(req, res) {++ const p = req.principal;++ if (p.kind === 'user') return p.requester.slice(5);++ const s = String(req.get('x-ov-subject') || '');++ if (SUBJECT_RE.test(s)) return s;++ http.sendProblem(res, 400, 'inventory.subject_required', { detail: 'A service acting for a person sends X-OV-Subject: usr_…', ctx: req.ov });++ return null;++ }++ const issuerOf = (req) => req.principal.requester;++ const wrap = (fn) => async (req, res) => {++ try { return await fn(req, res); } catch (err) {++ if (err instanceof InventoryError) return http.sendProblem(res, err.status, err.code, { detail: err.detail, ctx: req.ov });++ throw err;++ }++ };++++ r.get('/ping', limits.reads('inventory.item.read'), (_req, res) => res.json({ ok: true, service: config.service }));++++ // ── Public reads ──────────────────────────────────────++ r.get('/kinds', read, limits.reads('inventory.item.read'), wrap(async (_req, res) => res.json({ kinds: await inv.listKinds() })));++ r.get('/kinds/:id', read, limits.reads('inventory.item.read'), wrap(async (req, res) => {++ const k = await inv.getKind(req.params.id);++ return k ? res.json({ kind: k }) : http.sendProblem(res, 404, 'inventory.unknown_kind', { detail: 'no such kind', ctx: req.ov });++ }));++ r.get('/definitions', read, limits.reads('inventory.item.read'), wrap(async (req, res) => res.json({++ definitions: await inv.listDefinitions({ kind: req.query.kind ? String(req.query.kind) : null, issuer: req.query.issuer ? String(req.query.issuer) : null, limit: req.query.limit }),++ })));++ r.get('/definitions/:id', read, limits.reads('inventory.item.read'), wrap(async (req, res) => {++ const d = await inv.getDefinition(req.params.id);++ // A draft or an item in review is its issuer's to see.++ if (!d || (d.status !== 'published' && d.status !== 'retired' && req.principal.requester !== d.issuer)) return http.sendProblem(res, 404, 'inventory.definition_not_found', { detail: 'no such definition', ctx: req.ov });++ return res.json({ definition: d });++ }));++ r.get('/people/:subject/items', read, limits.reads('inventory.item.read'), wrap(async (req, res) => res.json(await inv.inventory(req.params.subject, {++ cursor: req.query.cursor || null, limit: req.query.limit, kind: req.query.kind ? String(req.query.kind) : null,++ }))));++ r.get('/people/:subject/equipped', read, limits.reads('inventory.item.read'), wrap(async (req, res) => {++ if (!SUBJECT_RE.test(req.params.subject)) return http.sendProblem(res, 404, 'inventory.unknown_subject', { detail: 'a usr_ subject', ctx: req.ov });++ return res.json((await inv.equipped([req.params.subject]))[0]);++ }));++ r.get('/equipped', read, limits.reads('inventory.item.read'), wrap(async (req, res) => {++ const subjects = String(req.query.subjects || '').split(',').map((x) => x.trim()).filter(Boolean);++ if (subjects.length > 100) return http.sendProblem(res, 400, 'inventory.too_many_subjects', { detail: 'at most 100 subjects per call', ctx: req.ov });++ return res.json({ equipped: await inv.equipped(subjects) });++ }));++++ // ── The person's own ──────────────────────────────────++ r.get('/me/items', principal.requireCapability('inventory.item.list'), limits.reads('inventory.item.list'), wrap(async (req, res) => {++ const subject = personOf(req, res);++ if (!subject) return undefined;++ return res.json(await inv.inventory(subject, { own: true, cursor: req.query.cursor || null, limit: req.query.limit, kind: req.query.kind ? String(req.query.kind) : null }));++ }));++ r.put('/me/equipped', principal.requireCapability('inventory.equip.manage'), limits.budget('inventory.equip'), wrap(async (req, res) => {++ const subject = personOf(req, res);++ if (!subject) return undefined;++ return res.json(await inv.equip(subject, req.principal.kind === 'user' ? req.principal.requester : issuerOf(req), req.body));++ }));++++ // ── Issuers ───────────────────────────────────────────++ r.post('/grants', principal.requireCapability('inventory.item.grant'), limits.budget('inventory.grant'), wrap(async (req, res) => {++ const out = await inv.grant(issuerOf(req), req.body);++ return res.status(out.created ? 201 : 200).json({ instance: out.instance, created: out.created });++ }));++ r.post('/instances/:id/consume', principal.requireCapability('inventory.item.consume'), limits.budget('inventory.grant'), wrap(async (req, res) => res.json({ instance: await inv.consume(issuerOf(req), req.params.id) })));++ r.post('/instances/:id/revoke', principal.requireCapability('inventory.item.consume'), limits.budget('inventory.grant'), wrap(async (req, res) => res.json({++ instance: await inv.consume(issuerOf(req), req.params.id, { revoke: true, reason: req.body && typeof req.body.reason === 'string' ? req.body.reason.slice(0, 200) : null }),++ })));++ r.post('/definitions', principal.requireCapability('inventory.definition.manage'), limits.budget('inventory.define'), wrap(async (req, res) => res.status(201).json({ definition: await inv.createDefinition(issuerOf(req), req.body) })));++ r.patch('/definitions/:id', principal.requireCapability('inventory.definition.manage'), limits.budget('inventory.define'), wrap(async (req, res) => res.json({ definition: await inv.updateDefinition(issuerOf(req), req.params.id, req.body) })));++++ return r;++}++++module.exports = { createApi };nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++++/**++ * Per-caller rate limits at OpenVibe.Inventory's API (openvibe-sdk/limits). The per-address limits in app.js stay; these++ * count requests by who makes them:++ *++ * a person user:usr_… (a Bearer token or this site's session)++ * an app, agent, service app:app_… / agent:agt_… / service:<slug>++ * anyone else ip:<address>++ *++ * Past a limit the route answers 429 problem+json `rate_limited` with Retry-After before it does any work; the++ * refusal is logged once and counted in inventory_rate_limited_total{limit,window}. Reads take INVENTORY_LIMITS_MINUTE /++ * INVENTORY_LIMITS_HOUR (120 and 3000); the product's expensive routes get their own numbers in BUDGETS below.++ * Counters live in this process unless VALKEY_URL is set.++ *++ * Never limited here: /api/health, /api/ready, /release.json, /metrics, sign-in and the pages.++ */++const { createActorLimiter, createValkeyLimitStore } = require('openvibe-sdk/limits');++++function caller(req) {++ const p = req.principal;++ if (p && p.requester) return p.requester;++ const v = req.viewer;++ if (v && v.kind === 'user' && v.subject) return `user:${v.subject}`;++ return `ip:${req.ip || (req.socket && req.socket.remoteAddress) || 'unknown'}`;++}++++/**++ * The product's expensive routes, each with its numbers per caller (a minute, an hour). A route takes its entry++ * with `limits.budget('<name>')`; an unknown name throws, so a route can never be counted by a budget that was++ * not declared here, e.g.++ *++ * 'inventory.thing.create': { minute: 6, hour: 60 },++ */++const BUDGETS = {++ // Equipping: a person changing what they wear (a few dozen a minute is a lot).++ 'inventory.equip': { minute: 30, hour: 600 },++ // Grants, consumes and revokes by an issuer: games and events grant in bursts.++ 'inventory.grant': { minute: 600, hour: 20000 },++ // Defining items.++ 'inventory.define': { minute: 30, hour: 300 },++};++++function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {++ const refused = registry++ ? registry.counter({ name: 'inventory_rate_limited_total', help: 'Requests refused 429 by a per-caller limit, by limit name and window', labelNames: ['limit', 'window'] })++ : null;++ const limiter = createActorLimiter({++ limits: { minute: config.limits.minute, hour: config.limits.hour },++ actor: enabled ? caller : () => null,++ now,++ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),++ onLimited(e) {++ // The caller is a subject, a principal or an address, never a token.++ log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);++ if (refused) refused.inc({ limit: e.name, window: e.window });++ },++ });++ limiter.reads = (name) => {++ const limit = limiter(name);++ return (req, res, next) => (req.method === 'GET' || req.method === 'HEAD' ? limit(req, res, next) : next());++ };++ const budgets = new Map(Object.entries(BUDGETS).map(([name, own]) => [name, limiter(name, own)]));++ limiter.budget = (name) => {++ const m = budgets.get(name);++ if (!m) throw new Error(`limits: no budget named ${name}`);++ return m;++ };++ return limiter;++}++++module.exports = { createCallerLimits, caller, BUDGETS };nnew file mode 100644@@@ -0,0 +1,103 @@++'use strict';++++/**++ * Crawl artifacts for inventory.openvibe.network, built with openvibe-shared/seo: robots.txt, sitemap.xml, llms.txt and++ * llms-full.txt, and the home page's JSON-LD. The public pages are for search engines and AI crawlers; sign-in and++ * the API are not.++ *++ * The product extends PAGE_TEXT and publicPages() with its own pages; the routes and headers here stay.++ */++const fs = require('fs');++const path = require('path');++const seo = require('openvibe-shared/seo');++const cache = require('openvibe-shared/cache-policy');++const { asyncRouter } = require('./router');++++const SITE_NAME = 'OpenVibe.Inventory';++const DESCRIPTION = 'OpenVibe.Inventory — Your items, on every OpenVibe site.';++const DISALLOW = ['/auth/', '/api/'];++++const PAGE_TEXT = {++ '/': ['OpenVibe.Inventory home', 'OpenVibe.Inventory: Your items, on every OpenVibe site.'],++ '/updates': ['What shipped on OpenVibe.Inventory', 'This site\'s update log, from the network changelog feed.'],++};++++function dayOf(ts) {++ const m = String(ts == null ? '' : ts).match(/^(\d{4}-\d{2}-\d{2})/);++ return m ? m[1] : null;++}++function siteUpdated() {++ try { return dayOf(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated); } catch { return null; }++}++++function homeJsonLd(config) {++ const site = String(config.baseUrl).replace(/\/+$/, '');++ return [++ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'BusinessApplication', keywords: 'openvibe' }),++ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),++ ];++}++++const publicPages = () => [++ { path: '/', changefreq: 'weekly', priority: 1.0 },++ { path: '/updates', changefreq: 'daily', priority: 0.5 },++];++++function createDiscoveryRoutes(ctx) {++ const { config } = ctx;++ const r = asyncRouter();++ const site = String(config.baseUrl).replace(/\/+$/, '');++ const abs = (p) => `${site}${p}`;++ const TEXT = cache.htmlHeaders({ maxAge: 3600 });++++ r.get('/robots.txt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', TEXT).send(++ '# inventory.openvibe.network: the public pages are for search and AI crawlers; sign-in and the API are not.\n'++ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));++ });++++ r.get('/llms.txt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({++ name: SITE_NAME,++ summary: 'OpenVibe.Inventory: Your items, on every OpenVibe site.',++ details: 'Every page is server-rendered and readable without JavaScript.',++ sections: [++ { title: 'Start here', links: [++ { title: 'OpenVibe.Inventory', url: abs('/'), note: 'Your items, on every OpenVibe site.' },++ { title: 'What shipped on OpenVibe.Inventory', url: abs('/updates') },++ ] },++ { title: 'Machine-readable', links: [++ { title: 'Sitemap', url: abs('/sitemap.xml') },++ { title: 'Full text for language models', url: abs('/llms-full.txt') },++ { title: 'Release metadata (JSON)', url: abs('/release.json') },++ ] },++ { title: 'Elsewhere', links: [++ { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts, apps and grants' },++ { title: 'OpenVibe.Services', url: 'https://openvibe.services', note: 'apps, keys and capability grants' },++ ] },++ ],++ }));++ });++++ r.get('/llms-full.txt', (_req, res) => {++ const pages = publicPages().map((p) => ({ url: p.path, title: PAGE_TEXT[p.path][0], text: PAGE_TEXT[p.path][1] }));++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({++ site: SITE_NAME,++ summary: 'Every public page of OpenVibe.Inventory, one line each.',++ base: site,++ maxBytes: 64 * 1024,++ sections: [{ title: 'Pages', pages }],++ }));++ });++++ r.get('/sitemap.xml', (_req, res) => {++ const lastmod = siteUpdated();++ const urls = publicPages().map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));++ res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));++ });++++ return r;++}++++module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, DESCRIPTION, SITE_NAME };nnew file mode 100644@@@ -0,0 +1,244 @@++'use strict';++++/**++ * Pages (ADR-054). Every page works without JavaScript and is server-rendered through openvibe-shared/shell.++ *++ * / what the inventory is, its kinds, a few items, how items arrive++ * /items every published item, by kind (?kind=), with its rarity++ * /items/:id one item: what it is, where it shows, who issues it, how many exist++ * /me the signed-in person's items; equip or take off with plain forms (POST /me/equip)++ * /u/:subject a person's public inventory and what they wear++ * /updates the update log++ *++ * Nothing here sells, buys, trades or converts an item (ADR-054 §5).++ */++const ovServe = require('openvibe-shared/serve');++const frame = require('openvibe-shared/frame');++const showcase = require('openvibe-shared/showcase');++const cache = require('openvibe-shared/cache-policy');++const express = require('express');++const { asyncRouter } = require('./router');++const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');++const { sameOrigin } = require('./principal');++const { html, raw } = require('../render/html');++const { send } = require('../render/layout');++const { InventoryError, SUBJECT_RE, RARITY } = require('../inventory/store');++++const SITE_NAME = 'OpenVibe.Inventory';++const TAGLINE = 'Your items, on every OpenVibe site.';++const RARITY_LABEL = { common: 'Common', uncommon: 'Uncommon', rare: 'Rare', epic: 'Epic', legendary: 'Legendary' };++const SURFACE_LABEL = { chat: 'chat', overlay: 'stream overlays', profile: 'your profile' };++// The ring icon each kind's card shows on the home page (openvibe-shared ov-icons); another kind gets the generic one.++const KIND_ICON = { 'live.name_effect': 'ov:text', 'live.particle': 'ov:theme', 'live.hat': 'ov:account', 'live.voice': 'ov:audio', 'live.chat_tag': 'ov:chat' };++const surfaceLabel = (s) => SURFACE_LABEL[s] || (s.startsWith('game:') ? `the game ${s.slice(5)}` : s);++const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;++++/** The item's picture: its emoji, or a Media image, in a square tile tinted by rarity. */++function art(d, { size = 'md' } = {}) {++ const a = d.art || {};++ if (a.media_id) return html`<span class="inv-art inv-art-${size} r-${d.rarity}"><img src="https://openvibe.media/o/${a.media_id}" alt="" loading="lazy" width="64" height="64"></span>`;++ return html`<span class="inv-art inv-art-${size} r-${d.rarity}" aria-hidden="true">${a.emoji || '◆'}</span>`;++}++const rarityBadge = (r) => html`<span class="inv-rarity r-${r}">${RARITY_LABEL[r] || r}</span>`;++++function card(d, { href = `/items/${d.id}`, extra = '', owned = null, worn = false } = {}) {++ return html`<li class="inv-card r-${d.rarity}${worn ? ' is-worn' : ''}">++ <a class="inv-card-link" href="${href}">${art(d)}<span class="inv-card-body"><span class="inv-card-name">${d.name}</span>${rarityBadge(d.rarity)}</span></a>++ ${d.description ? html`<p class="inv-card-desc">${d.description}</p>` : ''}${owned ? html`<p class="inv-card-meta">${owned}</p>` : ''}${extra}++</li>`;++}++++function createPageRoutes(ctx) {++ const { config, inv } = ctx;++ const r = asyncRouter();++ const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });++ const signedIn = (req) => (req.viewer && req.viewer.kind === 'user' && SUBJECT_RE.test(String(req.viewer.subject || '')) ? req.viewer.subject : null);++ const notFound = (req, res, what) => page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>${what} <a href="/items">Browse every item</a>.</p>` }, 404);++++ async function catalog() {++ const [kinds, defs] = await Promise.all([inv.listKinds(), inv.listDefinitions({ limit: 500 })]);++ const byKind = new Map(kinds.map((k) => [k.id, []]));++ for (const d of defs) if (byKind.has(d.kind)) byKind.get(d.kind).push(d);++ const order = (a, b) => RARITY.indexOf(b.rarity) - RARITY.indexOf(a.rarity) || a.name.localeCompare(b.name);++ for (const list of byKind.values()) list.sort(order);++ return { kinds, defs, byKind };++ }++++ // ── Home ─────────────────────────────────────────────────++ r.get('/', async (req, res) => {++ const { kinds, defs, byKind } = await catalog();++ const featured = [...defs].sort((a, b) => RARITY.indexOf(b.rarity) - RARITY.indexOf(a.rarity) || a.name.localeCompare(b.name)).slice(0, 6);++ const me = signedIn(req);++ const hero = showcase.hero({++ eyebrow: `${SITE_NAME} · part of OpenVibe`,++ title: 'Your items,',++ accent: 'on every OpenVibe site.',++ lede: 'One inventory for your whole OpenVibe account: name effects, particles, hats, voices and more. Earn them across the network, wear them in chat, on stream overlays and on your profile.',++ actions: me++ ? [{ label: 'Your inventory', href: '/me', primary: true }, { label: 'Browse every item', href: '/items' }]++ : [{ label: 'Browse every item', href: '/items', primary: true }, { label: 'Sign in to see yours', href: '/auth/login?next=%2Fme' }],++ note: 'Items are earned or given, never sold here. Open source (AGPL-3.0).',++ aside: { html: html`<div class="inv-hero-panel" aria-label="A few of the rarest items"><p class="inv-hero-title">From the collection</p><ul class="inv-hero-grid">${featured.map((d) => html`<li><a href="/items/${d.id}" title="${d.name} · ${RARITY_LABEL[d.rarity]}">${art(d, { size: 'lg' })}<span class="inv-hero-name">${d.name}</span></a></li>`)}</ul></div>`.toString() },++ });++ page(req, res, {++ index: true, cache: me ? null : PUBLIC_CACHE,++ jsonLd: homeJsonLd(config),++ styles: [showcase.STYLESHEET],++ body: html`${raw(hero)}++${raw(showcase.features({++ title: 'What you can collect',++ lede: `${plural(defs.length, 'item', 'items')} in ${plural(kinds.length, 'kind', 'kinds')} today. Each kind knows where it shows, so a new kind is all it takes for a new site or game to join.`,++ // Kinds that have items, the biggest first; an empty kind waits until an issuer defines something in it.++ items: kinds.filter((k) => (byKind.get(k.id) || []).length).sort((a, b) => byKind.get(b.id).length - byKind.get(a.id).length).map((k) => ({ icon: KIND_ICON[k.id] || 'ov:ov', title: `${k.name} · ${(byKind.get(k.id) || []).length}`, text: `${k.description || ''} Shows in ${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ')}.`, href: `/items?kind=${encodeURIComponent(k.id)}` })),++ }))}++${raw(showcase.steps({++ title: 'How items reach you',++ items: [++ { title: 'Earn or receive them', text: 'Sites, games and quests across the network give items for what you do. Each one can only give its own items.' },++ { title: 'Keep them in one place', text: 'Everything you own is here, with where it came from and when.' },++ { title: 'Wear them everywhere', text: 'Equip one item per slot. Every site that shows that kind shows what you chose.' },++ { title: 'Never for sale', text: 'Nothing here is bought, sold, traded or turned into money. Rarity is shown as it is.' },++ ],++ }))}++${raw(showcase.cta({ title: me ? 'See what you have' : 'Already earned something?', text: me ? 'Your items and what you are wearing, on one page.' : 'Sign in with your OpenVibe account to see your inventory and choose what to wear.', actions: me ? [{ label: 'Your inventory', href: '/me' }] : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Fme' }, { label: 'The API', href: '/api/v1/kinds' }] }))}`,++ });++ });++++ // ── The catalog ──────────────────────────────────────────++ r.get('/items', async (req, res) => {++ const { kinds, defs, byKind } = await catalog();++ const only = req.query.kind ? String(req.query.kind) : null;++ if (only && !byKind.has(only)) return notFound(req, res, 'There is no such kind of item.');++ // All: the kinds that have items; one kind: that kind, even while it is empty.++ const shown = kinds.filter((k) => (only ? k.id === only : (byKind.get(k.id) || []).length));++ page(req, res, {++ index: !only, cache: signedIn(req) ? null : PUBLIC_CACHE,++ title: only ? `${shown[0].name} items` : 'Every item',++ description: `${plural(defs.length, 'item', 'items')} you can earn and wear across OpenVibe, with their rarity.`,++ body: html`<h1>${only ? `${shown[0].name} items` : 'Every item'}</h1>++<p class="lede">${only ? shown[0].description : `${plural(defs.length, 'item', 'items')} in ${plural(kinds.length, 'kind', 'kinds')}. Rarity is shown as it is.`}</p>++<nav class="inv-tabs" aria-label="Kinds"><a href="/items"${only ? '' : raw(' aria-current="page"')}>All</a>${kinds.map((k) => html`<a href="/items?kind=${encodeURIComponent(k.id)}"${only === k.id ? raw(' aria-current="page"') : ''}>${k.name} <span class="muted">${(byKind.get(k.id) || []).length}</span></a>`)}</nav>++${shown.map((k) => html`<section class="inv-kind" aria-labelledby="k-${k.id.replace(/\W/g, '-')}">++ <h2 id="k-${k.id.replace(/\W/g, '-')}">${k.name} <span class="muted small">shows in ${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ')}</span></h2>++ ${(byKind.get(k.id) || []).length ? html`<ul class="inv-grid">${byKind.get(k.id).map((d) => card(d))}</ul>` : html`<p class="muted">No ${k.name.toLowerCase()} items yet.</p>`}++</section>`)}`,++ });++ });++++ r.get('/items/:id', async (req, res) => {++ const d = await inv.getDefinition(req.params.id);++ if (!d || (d.status !== 'published' && d.status !== 'retired')) return notFound(req, res, 'There is no such item.');++ const k = await inv.getKind(d.kind);++ const issuer = d.issuer.startsWith('service:') ? `OpenVibe.${d.issuer.slice(8).replace(/^./, (c) => c.toUpperCase())}` : d.credit && d.credit.name ? d.credit.name : 'a creator';++ page(req, res, {++ index: d.status === 'published', cache: signedIn(req) ? null : PUBLIC_CACHE,++ title: `${d.name} · ${RARITY_LABEL[d.rarity]} ${k.name.toLowerCase()}`,++ description: d.description || `${d.name}, a ${RARITY_LABEL[d.rarity].toLowerCase()} ${k.name.toLowerCase()} on OpenVibe.`,++ body: html`<p class="crumbs"><a href="/items">Every item</a> › <a href="/items?kind=${encodeURIComponent(k.id)}">${k.name}</a></p>++<div class="inv-detail r-${d.rarity}">++ ${art(d, { size: 'xl' })}++ <div>++ <h1>${d.name}</h1>++ <p>${rarityBadge(d.rarity)} <span class="muted">${k.name}</span>${d.status === 'retired' ? html` <span class="badge warn">No longer given</span>` : ''}</p>++ ${d.description ? html`<p class="lede">${d.description}</p>` : ''}++ </div>++</div>++<ul class="stats">++ <li><b>${d.supply.issued}</b> owned across OpenVibe</li>++ <li><b>${d.supply.cap == null ? 'Unlimited' : `${d.supply.cap - d.supply.issued} of ${d.supply.cap}`}</b> ${d.supply.cap == null ? 'supply' : 'left'}</li>++ <li><b>${k.slots.length ? 'Wearable' : 'Collectible'}</b> ${k.slots.length ? `in the ${k.slots.join(', ')} slot` : 'shown on your profile'}</li>++</ul>++<h2>Where it shows</h2>++<p>${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ').replace(/^./, (c) => c.toUpperCase())}.</p>++<h2>Who gives it</h2>++<p>${issuer}. Only its issuer can give this item; it is never sold here.</p>++<p class="muted small">Item <code>${d.id}</code>${(d.aliases || []).length ? html` · also known as <code>${d.aliases.join(', ')}</code>` : ''} · <a href="/api/v1/definitions/${d.id}">JSON</a></p>`,++ });++ });++++ // ── Your inventory ───────────────────────────────────────++ async function ownedView(subject, { own }) {++ const [list, eq, kinds] = await Promise.all([inv.inventory(subject, { own, limit: 200 }), inv.equipped([subject]), inv.listKinds()]);++ const worn = new Set(Object.values(eq[0] ? eq[0].slots : {}).map((x) => x.instance_id));++ const owned = list.instances.filter((i) => i.state === 'owned');++ const byKind = new Map(kinds.map((k) => [k.id, []]));++ for (const i of owned) { const d = list.definitions[i.definition_id]; if (d && byKind.has(d.kind)) byKind.get(d.kind).push({ i, d }); }++ return { kinds, byKind, worn, owned, more: !!list.next_cursor };++ }++++ r.get('/me', async (req, res) => {++ const me = signedIn(req);++ if (!me) return res.redirect(303, '/auth/login?next=%2Fme');++ const { kinds, byKind, worn, owned, more } = await ownedView(me, { own: true });++ const done = req.query.done ? String(req.query.done) : null;++ const error = req.query.error ? String(req.query.error).slice(0, 200) : null;++ page(req, res, {++ title: 'Your inventory',++ body: html`<h1>Your inventory</h1>++<p class="lede">${owned.length ? `${plural(owned.length, 'item', 'items')}. Choose one per slot to wear it on every site that shows it.` : 'Nothing yet. Items arrive as you take part across OpenVibe: streams, quests, games and events.'}</p>++${done ? html`<p class="notice ok" role="status">${done === 'equipped' ? 'Equipped. It shows everywhere that kind is shown.' : 'Taken off.'}</p>` : ''}++${error ? html`<p class="notice bad" role="alert">${error}</p>` : ''}++<p><a href="/u/${me}">See your public page</a> · <a href="/items">Browse every item</a></p>++${kinds.filter((k) => byKind.get(k.id).length).map((k) => html`<section class="inv-kind">++ <h2>${k.name} <span class="muted small">${plural(byKind.get(k.id).length, 'item', 'items')}</span></h2>++ <ul class="inv-grid">${byKind.get(k.id).map(({ i, d }) => card(d, {++ owned: `Since ${String(i.acquired_at).slice(0, 10)}${i.serial ? ` · #${i.serial}` : ''}`,++ worn: worn.has(i.id),++ extra: k.slots.length ? html`<form method="post" action="/me/equip" class="inv-equip">++ <input type="hidden" name="kind" value="${k.id}"><input type="hidden" name="slot" value="${k.slots[0]}">++ ${worn.has(i.id)++ ? html`<input type="hidden" name="instance_id" value=""><span class="inv-worn">Wearing</span><button type="submit" class="inv-btn">Take off</button>`++ : html`<input type="hidden" name="instance_id" value="${i.id}"><button type="submit" class="inv-btn primary" aria-label="Wear ${d.name}">Wear</button>`}++ </form>` : '',++ }))}</ul>++</section>`)}++${more ? html`<p class="muted">Showing the newest 200. The full list is at <a href="/api/v1/me/items">/api/v1/me/items</a>.</p>` : ''}`,++ });++ });++++ r.post('/me/equip', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {++ const me = signedIn(req);++ if (!me) return res.redirect(303, '/auth/login?next=%2Fme');++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('That form must come from this site.');++ const b = req.body || {};++ try {++ await inv.equip(me, `user:${me}`, { kind: String(b.kind || ''), slot: String(b.slot || ''), instance_id: b.instance_id ? String(b.instance_id) : null });++ return res.redirect(303, `/me?done=${b.instance_id ? 'equipped' : 'cleared'}`);++ } catch (err) {++ if (err instanceof InventoryError) return res.redirect(303, `/me?error=${encodeURIComponent(err.detail || err.code)}`);++ throw err;++ }++ });++++ // ── A person's public page ───────────────────────────────++ r.get('/u/:subject', async (req, res) => {++ const subject = String(req.params.subject);++ if (!SUBJECT_RE.test(subject)) return notFound(req, res, 'There is no such person.');++ const { kinds, byKind, worn, owned } = await ownedView(subject, { own: false });++ const mine = signedIn(req) === subject;++ const wornItems = owned.filter((i) => worn.has(i.id));++ page(req, res, {++ index: false, cache: signedIn(req) ? null : PUBLIC_CACHE,++ title: mine ? 'Your public inventory' : 'An OpenVibe inventory',++ body: html`<h1>${mine ? 'Your public inventory' : 'An OpenVibe inventory'}</h1>++<p class="lede">${plural(owned.length, 'item', 'items')}${wornItems.length ? `, ${wornItems.length} worn` : ''}.${mine ? ' This is what anyone sees.' : ''}</p>++${kinds.filter((k) => byKind.get(k.id).length).map((k) => html`<section class="inv-kind"><h2>${k.name}</h2>++ <ul class="inv-grid">${byKind.get(k.id).map(({ i, d }) => card(d, { worn: worn.has(i.id), extra: worn.has(i.id) ? html`<p class="inv-worn">Wearing</p>` : '' }))}</ul></section>`)}++${!owned.length ? html`<p class="muted">Nothing to show yet.</p>` : ''}`,++ });++ });++++ // ── The update log ───────────────────────────────────────++ r.get('/updates', (req, res) => page(req, res, {++ index: true, cache: PUBLIC_CACHE,++ title: `What shipped on ${SITE_NAME}`,++ body: raw(frame.updatesBody({ service: 'inventory', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`),++ }));++++ // ── Discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt ──++ r.use(createDiscoveryRoutes(ctx));++ return r;++}++++module.exports = { createPageRoutes };nnew file mode 100644@@@ -0,0 +1,119 @@++'use strict';++++/**++ * Who is calling /api/v1 — req.principal:++ *++ * { kind: 'user', requester: 'user:usr_…', project: null, viaSession } a person: their Network token as a Bearer,++ * or this site's session cookie++ * { kind: 'app', requester: 'app:app_…' | 'agent:agt_…' | 'service:x', project: 'prj_…' | null, claims }++ * a Network app, agent or service token for++ * audience inventory.openvibe.network; each route names++ * ONE capability it needs++ * { kind: 'anonymous' }++ *++ * A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the++ * session or to anonymous. A write made with the session cookie must come from this site (Sec-Fetch-Site++ * same-origin, or an Origin equal to ours): another site cannot make a signed-in visitor act here.++ * A person acting for themself needs no capability; an app, agent or service needs the one the route names.++ */++const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');++++const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++// The product fills this in: one entry per capability its routes name, in openvibe-contracts++// manifests/capabilities/<inventory>.*. requireCapability('<name>') refuses a name that is not listed here, so a++// route can never be guarded by a capability the service does not declare.++const CAPABILITIES = [++ 'inventory.item.read', 'inventory.item.list', 'inventory.equip.manage', 'inventory.item.grant', 'inventory.item.consume',++ 'inventory.definition.manage', 'inventory.definition.review',++];++++function decodePayload(token) {++ const parts = String(token || '').split('.');++ if (parts.length !== 3) return null;++ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }++}++++function requesterOfSub(sub) {++ if (sub.startsWith('svc:')) return `service:${sub.slice(4)}`;++ return sub; // app:app_…, agent:agt_…, mod:mod_…++}++++function sameOrigin(req, baseUrl) {++ const site = String(req.get('sec-fetch-site') || '');++ if (site) return site === 'same-origin';++ const origin = req.get('origin');++ if (origin) return origin === baseUrl || origin === `${req.protocol}://${req.get('host')}`;++ return false;++}++++function createPrincipal({ config, keys }) {++ async function resolve(req) {++ const header = String(req.headers.authorization || '');++ if (header.startsWith('Bearer ')) {++ const token = header.slice(7).trim();++ const payload = decodePayload(token);++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {++ const publicKey = await keys.pemForToken(token);++ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });++ if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };++ const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;++ return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, claims: r.claims } };++ }++ // A person's own Network token (the same kind this site's session holds).++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience });++ if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };++ if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };++ if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };++ return { principal: { kind: 'user', requester: `user:${v.claims.subject_id}`, project: null, viaSession: false } };++ }++ const viewer = req.viewer;++ if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {++ return { principal: { kind: 'user', requester: `user:${viewer.subject}`, project: null, viaSession: true } };++ }++ return { principal: { kind: 'anonymous' } };++ }++++ async function middleware(req, res, next) {++ const r = await resolve(req);++ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });++ req.principal = r.principal;++ return next();++ }++++ /** One route's guard: a person needs nothing more (writes by cookie must be same-origin); an app the capability. */++ function require_(cap) {++ if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.Inventory's capabilities`);++ return function guard(req, res, next) {++ const p = req.principal;++ if (p.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at inventory.openvibe.network, or send a Network token (a person\'s, or an app\'s with this capability for inventory.openvibe.network).', ctx: req.ov });++ if (p.kind === 'user') {++ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'request.cross_site', { detail: 'A signed-in request that changes something must come from inventory.openvibe.network itself.', ctx: req.ov });++ return next();++ }++ const decision = capabilities.check(p.claims, cap);++ if (decision.allowed) return next();++ return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${cap} not granted`, ctx: req.ov });++ };++ }++++ /**++ * A public read's guard: anyone may read without a token; a token that is presented must hold the capability (an++ * app, agent or service reading on its own account), and a person reads with their own.++ */++ function requireRead(cap) {++ if (!CAPABILITIES.includes(cap)) throw new Error(`requireRead(${cap}): not one of OpenVibe.Inventory's capabilities`);++ return function readGuard(req, res, next) {++ const p = req.principal;++ if (p.kind !== 'app') return next();++ const decision = capabilities.check(p.claims, cap);++ if (decision.allowed) return next();++ return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${cap} not granted`, ctx: req.ov });++ };++ }++++ return { middleware, resolve, requireCapability: require_, requireRead };++}++++module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };nnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++++/**++ * express.Router() whose get/post handlers may be async: a rejected promise goes to next(err) (the++ * app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.++ */++const express = require('express');++++function asyncRouter() {++ const r = express.Router();++ for (const method of ['get', 'post']) {++ const orig = r[method].bind(r);++ r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {++ try {++ const out = h(req, res, next);++ if (out && typeof out.catch === 'function') out.catch(next);++ } catch (err) { next(err); }++ })));++ }++ return r;++}++++module.exports = { asyncRouter };nnew file mode 100644@@@ -0,0 +1,51 @@++'use strict';++++/**++ * OpenVibe.Inventory — process entry. `node server/index.js`++ * Listens on PORT (5030) behind nginx (deploy/).++ */++const { createApp } = require('./app');++const { gracefulStop } = require('openvibe-sdk/service');++const { startSubscriptions } = require('openvibe-sdk/account-data');++++/**++ * The process stop (openvibe-sdk/service): the HTTP drain runs, then the timers clear, the Events subscriptions and the++ * outbox relay stop, the JWKS refresher stops and the store closes. Exported so a test can inject `exit` and++ * `signals: false`. `extra` is what start() adds (the subscriptions).++ */++function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {++ return gracefulStop({++ name: 'OpenVibe.Inventory', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,++ close: [() => { for (const t of timers) clearInterval(t); }, ...extra, () => ctx.outbox.stop(), () => ctx.keys.client.stop(), () => ctx.s.close()],++ });++}++++async function start() {++ const { app, ctx } = await createApp();++ const { config } = ctx;++++ const server = app.listen(config.port, config.host, () => {++ console.log(`[OpenVibe.Inventory] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.s.db.store})`);++ });++ server.keepAliveTimeout = 65_000;++ ctx.keys.client.start();++ ctx.outbox.start();++ // The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,++ // INVENTORY_EVENTS_SECRET or the client secret.++ const subscriptions = startSubscriptions({++ eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],++ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ });++ // Sent events older than a week go (openvibe-sdk outbox prune), every six hours.++ const prune = setInterval(() => { Promise.resolve(ctx.outbox.outbox.prune()).catch(() => {}); }, 6 * 3600 * 1000);++ prune.unref();++++ createLifecycle({ server, ctx, timers: [prune], extra: [() => { if (subscriptions) subscriptions.stop(); }] });++ return { server, ctx };++}++++if (require.main === module) {++ start().catch((err) => { console.error('[OpenVibe.Inventory] failed to start:', err); process.exit(1); });++}++++module.exports = { start, createLifecycle };nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++++/**++ * Account export and deletion → Inventory (ADR-033, ADR-054 §7; openvibe-sdk/account-data).++ *++ * export the person's instances (every state), what they have equipped and their ledger rows++ * deletion their equipped set and instances are deleted (grant receipts go with the instances, ON DELETE CASCADE);++ * their ledger rows stay without their subject or name, so supply counts and each item's history stay++ * true; a creator's definitions stay, without the creator credit++ *++ * Nothing exported is a secret.++ */++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++++const TABLES = [++ // Equipped rows reference instances, so they go first.++ { table: 'inventory_equipped', subject: 'owner_subject', file: 'equipped.json', columns: ['kind', 'slot', 'instance_id', 'updated_at'], order: 'updated_at' },++ { table: 'inventory_instances', subject: 'owner_subject', file: 'items.json', columns: ['id', 'definition_id', 'origin', 'state', 'attributes', 'serial', 'acquired_at'], order: 'acquired_at' },++ { table: 'inventory_ledger', subject: 'owner_subject', file: 'history.json', columns: ['at', 'action', 'instance_id', 'definition_id', 'actor', 'reason'], order: 'id', erase: { anonymize: {} } },++ { table: 'inventory_definitions', subject: 'credit_subject', file: null, erase: { anonymize: { credit_name: null } } },++];++++/** A ledger row the person acted in (an equip they made) keeps the row and loses their name. */++async function extraErase(t, subjects, counts) {++ counts.add(counts.retained, 'tombstones', await t.exec("UPDATE inventory_ledger SET actor = 'user:deleted' WHERE actor = ANY($1::text[])", [subjects.map((x) => `user:${x}`)]));++}++++function create({ db, log = console } = {}) {++ return createAccountData({ db, service: 'inventory', tables: TABLES, extraErase, log });++}++++module.exports = { create, TABLES, TOPICS };nnew file mode 100644@@@ -0,0 +1,103 @@++'use strict';++++/**++ * ADR-054 §8, convert and verify: Live's cosmetics (user_cosmetics and user_equipped in Live's database, read only) become++ * instances and equipped slots here, for each person's Network subject. scripts/import-live.js runs these.++ *++ * importLive({ live, inv, db, apply }) grants each unlock as an instance with origin `migrated`, issued by++ * service:live, keeping its unlock time, under the grant key++ * live-migration:<user_id>:<item_id> (a second run grants nothing again);++ * then equips each person's slots. Without apply, counts what it would do.++ * verifyLive({ live, inv, db }) each person's unlocked set and equipped slots, row for row → differences++ * (items no longer in Live's catalog are listed apart, as unknown)++ *++ * A Live account without a Network subject cannot hold items; it is counted, never guessed.++ */++const { LIVE } = require('./seed');++++const SLOT_KIND = { name_effect: 'live.name_effect', particle: 'live.particle', hat: 'live.hat', voice: 'live.voice' };++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++/** Live's rows per subject: subject → { userId, items: Map(item_id → unlocked_at), slots: Map(slot → item_id) }. */++async function liveRows(live) {++ const items = await live.many(`SELECT u.id AS user_id, u.subject_id, c.item_id, c.unlocked_at++ FROM user_cosmetics c JOIN users u ON u.id = c.user_id ORDER BY u.id, c.item_id`);++ const slots = await live.many(`SELECT u.id AS user_id, u.subject_id, e.slot, e.item_id++ FROM user_equipped e JOIN users u ON u.id = e.user_id ORDER BY u.id, e.slot`);++ const by = new Map();++ const noSubject = new Set();++ const of = (r) => {++ if (!SUBJECT_RE.test(String(r.subject_id || ''))) { noSubject.add(Number(r.user_id)); return null; }++ if (!by.has(r.subject_id)) by.set(r.subject_id, { userId: Number(r.user_id), items: new Map(), slots: new Map() });++ return by.get(r.subject_id);++ };++ for (const r of items) { const p = of(r); if (p) p.items.set(r.item_id, r.unlocked_at); }++ for (const r of slots) { const p = of(r); if (p) p.slots.set(r.slot, r.item_id); }++ return { by, noSubject: noSubject.size, unlocks: items.length, equippedSlots: slots.length };++}++++/** Live's ov_now() text ('2026-09-01 12:00:00', UTC) or an ISO time → ISO, else null. */++function isoOf(v) {++ if (!v) return null;++ const d = new Date(String(v).includes('T') ? String(v) : `${String(v).replace(' ', 'T')}Z`);++ return Number.isNaN(d.getTime()) ? null : d.toISOString();++}++++async function importLive({ live, inv, db, apply = false, log = console }) {++ const rows = await liveRows(live);++ const out = { people: rows.by.size, unlocks: rows.unlocks, equippedSlots: rows.equippedSlots, noSubject: rows.noSubject, granted: 0, alreadyHere: 0, equipped: 0, unknown: [], notOwned: 0 };++ const defs = new Map();++ const def = async (alias) => { if (!defs.has(alias)) defs.set(alias, await inv.byAlias(LIVE, alias)); return defs.get(alias); };++ for (const [subject, p] of rows.by) {++ for (const [itemId, unlockedAt] of p.items) {++ const d = await def(itemId);++ if (!d) { out.unknown.push(itemId); continue; }++ if (!apply) { out.granted++; continue; }++ const r = await inv.grant(LIVE, { definition_id: d.id, subject, idempotency_key: `live-migration:${p.userId}:${itemId}`, origin: 'migrated', reason: 'moved from OpenVibe.Live', acquired_at: isoOf(unlockedAt) || undefined }, { allowMigrated: true });++ if (r.created) out.granted++; else out.alreadyHere++;++ }++ for (const [slot, itemId] of p.slots) {++ const kind = SLOT_KIND[slot];++ const d = kind ? await def(itemId) : null;++ if (!d) { out.unknown.push(itemId); continue; }++ if (!apply) { out.equipped++; continue; }++ const inst = await db.maybe("SELECT id FROM inventory_instances WHERE definition_id = $1 AND owner_subject = $2 AND state = 'owned' LIMIT 1", [d.id, subject]);++ if (!inst) { out.notOwned++; log.warn(`[import-live] ${subject} wears ${itemId} in ${slot} without owning it in Live; left empty`); continue; }++ await inv.equip(subject, LIVE, { kind, slot, instance_id: inst.id });++ out.equipped++;++ }++ }++ out.unknown = [...new Set(out.unknown)];++ return out;++}++++async function verifyLive({ live, inv, db }) {++ const rows = await liveRows(live);++ const differences = [];++ const unknown = new Set();++ const known = new Map();++ const isKnown = async (alias) => { if (!known.has(alias)) known.set(alias, !!(await inv.byAlias(LIVE, alias))); return known.get(alias); };++ for (const [subject, p] of rows.by) {++ const mine = await db.many(`SELECT a.alias FROM inventory_instances i JOIN inventory_definition_aliases a ON a.definition_id = i.definition_id AND a.issuer = $2++ WHERE i.owner_subject = $1 AND i.state = 'owned'`, [subject, LIVE]);++ const have = new Set(mine.map((r) => r.alias));++ const missing = [];++ for (const x of p.items.keys()) {++ if (have.has(x)) continue;++ // An item Live no longer has in its catalog was never imported (importLive names it); it is not a difference.++ if (await isKnown(x)) missing.push(x); else unknown.add(x);++ }++ const eq = (await inv.equipped([subject]))[0].slots;++ const worn = new Map();++ for (const [key, v] of Object.entries(eq)) {++ const a = await db.maybe('SELECT alias FROM inventory_definition_aliases WHERE definition_id = $1 AND issuer = $2', [v.definition_id, LIVE]);++ worn.set(key.split(':')[1], a ? a.alias : null);++ }++ // A slot Live has but whose item Live does not own is left empty on purpose (importLive counts it as notOwned).++ const slots = [...p.slots].filter(([slot, item]) => p.items.has(item) && worn.get(slot) !== item).map(([slot]) => slot);++ if (missing.length || slots.length) differences.push({ subject, missing, slots });++ }++ return { people: rows.by.size, unlocks: rows.unlocks, equippedSlots: rows.equippedSlots, noSubject: rows.noSubject, differences, unknown: [...unknown] };++}++++module.exports = { importLive, verifyLive, liveRows, isoOf, SLOT_KIND };nnew file mode 100644@@@ -0,0 +1,29 @@++'use strict';++++/**++ * What OpenVibe.Inventory registers at boot (ADR-054 §8), idempotently:++ * server/data/kinds.json the kinds (contracts with renderers); an existing kind is updated in place++ * server/data/live-definitions.json Live's cosmetics catalog as definitions issued by service:live, found again by their++ * alias (Live's item id); a definition already there is left as it is++ * A boot that changes nothing writes nothing.++ */++const path = require('path');++++const DATA = path.join(__dirname, '..', 'data');++const LIVE = 'service:live';++++async function seed(inv, { log = console } = {}) {++ const { kinds } = require(path.join(DATA, 'kinds.json'));++ for (const k of kinds) await inv.upsertKind(k);++ const { definitions } = require(path.join(DATA, 'live-definitions.json'));++ let made = 0;++ for (const d of definitions) {++ if (await inv.byAlias(LIVE, d.alias)) continue;++ await inv.createDefinition(LIVE, { kind: d.kind, name: d.name, description: d.description || undefined, art: d.art, rarity: d.rarity, attributes: d.attributes, aliases: [d.alias] }, { status: 'published' });++ made++;++ }++ if (made) log.log(`[OpenVibe.Inventory] seeded ${made} of Live's cosmetics as definitions`);++ return { kinds: kinds.length, definitions: made };++}++++module.exports = { seed, LIVE };nnew file mode 100644@@@ -0,0 +1,342 @@++'use strict';++++/**++ * The inventory (ADR-054): kinds, definitions, instances, the equipped set and the ledger, and the only writes to them.++ * Every movement writes one ledger row and one event in the same transaction as the change.++ *++ * kinds list, get, upsert (seed only: kinds are contracts, registered at boot from server/data/kinds.json)++ * definitions list, get, byAlias, create, update; only the kind's issuer defines items in its namespace++ * grant an issuer gives one instance to a person: idempotent per (issuer, key), honours the supply cap; a++ * non-stackable item the person already owns answers the instance they have++ * consume an issuer uses up or revokes an instance it issued++ * inventory a person's instances with their definitions (public: owned ones of published or retired items)++ * equipped many people's equipped sets in one query; equip, clear++ *++ * Nothing here sells, buys, trades or converts an item (ADR-054 §5): there is no price column and no such route.++ */++const Ajv = require('ajv/dist/2020');++++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const DEF_RE = /^itd_[0-9A-HJKMNP-TV-Z]{26}$/;++const INST_RE = /^inv_[0-9A-HJKMNP-TV-Z]{26}$/;++const RARITY = ['common', 'uncommon', 'rare', 'epic', 'legendary'];++++class InventoryError extends Error {++ constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail; }++}++const fail = (status, code, detail) => { throw new InventoryError(status, code, detail); };++++/** 'service:live' → { type: 'service', id: 'live' } (the event envelope's actor). */++function actorOf(requester) {++ const [type, ...rest] = String(requester).split(':');++ return { type: type === 'user' ? 'user' : type === 'app' ? 'app' : type === 'agent' ? 'agent' : 'service', id: rest.join(':') };++}++++function kindRow(r) {++ return { id: r.id, name: r.name, description: r.description || undefined, slots: r.slots, surfaces: r.surfaces, attributes: r.attribute_schema, stackable: r.stackable, issuer: r.issuer };++}++++function definitionRow(r, aliases = []) {++ const out = {++ id: r.id, kind: r.kind, name: r.name, art: r.art, rarity: r.rarity, attributes: r.attributes || {}, issuer: r.issuer,++ supply: { cap: r.supply_cap == null ? null : Number(r.supply_cap), issued: Number(r.supply_issued) },++ status: r.status, created_at: r.created_at, updated_at: r.updated_at,++ };++ if (r.description) out.description = r.description;++ if (aliases.length) out.aliases = aliases;++ if (r.published_at) out.published_at = r.published_at;++ if (r.credit_subject || r.credit_name) out.credit = { ...(r.credit_subject ? { subject: r.credit_subject } : {}), ...(r.credit_name ? { name: r.credit_name } : {}) };++ return out;++}++++function instanceRow(r) {++ const out = { id: r.id, definition_id: r.definition_id, owner: r.owner_subject, origin: r.origin, state: r.state, acquired_at: r.acquired_at };++ if (r.attributes && Object.keys(r.attributes).length) out.attributes = r.attributes;++ if (r.serial != null) out.serial = Number(r.serial);++ return out;++}++++const encodeCursor = (r) => Buffer.from(`${r.acquired_at}|${r.id}`).toString('base64url');++function decodeCursor(c) {++ if (!c) return null;++ const [at, id] = Buffer.from(String(c), 'base64url').toString('utf8').split('|');++ return at && INST_RE.test(id || '') ? { at, id } : fail(400, 'inventory.bad_cursor', 'cursor is not one this service gave');++}++++function createInventory({ s, outbox, log = console }) {++ const db = s.db;++ const ajv = new Ajv({ strict: false, allErrors: false });++ const validators = new Map(); // kind id + updated_at → compiled attribute schema++++ async function emit(event_type, { subject, visibility, actor, payload }) {++ await outbox.emit({ event_type, version: 1, source: 'inventory', actor: actorOf(actor), subject, visibility, payload });++ }++++ async function ledger(action, { owner = null, instance = null, definition = null, actor, reason = null, detail = {} }) {++ await db.exec(`INSERT INTO inventory_ledger (at, action, owner_subject, instance_id, definition_id, actor, reason, detail)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [s.iso(), action, owner, instance, definition, actor, reason, JSON.stringify(detail)]);++ }++++ // ── Kinds ─────────────────────────────────────────────++ async function getKind(id) {++ const r = await db.maybe('SELECT * FROM inventory_kinds WHERE id = $1', [String(id || '')]);++ return r ? kindRow(r) : null;++ }++ async function listKinds() { return (await db.many('SELECT * FROM inventory_kinds ORDER BY id')).map(kindRow); }++ async function upsertKind(k) {++ const now = s.iso();++ await db.exec(`INSERT INTO inventory_kinds (id, name, description, slots, surfaces, attribute_schema, stackable, issuer, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)++ ON CONFLICT (id) DO UPDATE SET name = excluded.name, description = excluded.description, slots = excluded.slots, surfaces = excluded.surfaces,++ attribute_schema = excluded.attribute_schema, stackable = excluded.stackable, issuer = excluded.issuer, updated_at = excluded.updated_at++ WHERE (inventory_kinds.name, inventory_kinds.description, inventory_kinds.slots, inventory_kinds.surfaces, inventory_kinds.attribute_schema, inventory_kinds.stackable, inventory_kinds.issuer)++ IS DISTINCT FROM (excluded.name, excluded.description, excluded.slots, excluded.surfaces, excluded.attribute_schema, excluded.stackable, excluded.issuer)`,++ [k.id, k.name, k.description || null, JSON.stringify(k.slots || []), JSON.stringify(k.surfaces || []), JSON.stringify(k.attribute_schema || { type: 'object' }), !!k.stackable, k.issuer, now]);++ }++ async function checkAttributes(kind, attributes) {++ const row = await db.maybe('SELECT attribute_schema, updated_at FROM inventory_kinds WHERE id = $1', [kind]);++ const key = `${kind}@${row.updated_at}`;++ if (!validators.has(key)) validators.set(key, ajv.compile(row.attribute_schema));++ const validate = validators.get(key);++ if (!validate(attributes || {})) fail(422, 'inventory.bad_attributes', `attributes do not fit ${kind}: ${ajv.errorsText(validate.errors)}`);++ }++++ // ── Definitions ───────────────────────────────────────++ async function aliasesOf(ids) {++ if (!ids.length) return new Map();++ const rows = await db.many('SELECT definition_id, alias FROM inventory_definition_aliases WHERE definition_id = ANY($1::text[]) ORDER BY alias', [ids]);++ const m = new Map();++ for (const r of rows) { if (!m.has(r.definition_id)) m.set(r.definition_id, []); m.get(r.definition_id).push(r.alias); }++ return m;++ }++ async function getDefinition(id) {++ if (!DEF_RE.test(String(id || ''))) return null;++ const r = await db.maybe('SELECT * FROM inventory_definitions WHERE id = $1', [id]);++ if (!r) return null;++ return definitionRow(r, (await aliasesOf([r.id])).get(r.id) || []);++ }++ async function byAlias(issuer, alias) {++ const r = await db.maybe('SELECT definition_id FROM inventory_definition_aliases WHERE issuer = $1 AND alias = $2', [issuer, String(alias || '')]);++ return r ? await getDefinition(r.definition_id) : null;++ }++ async function listDefinitions({ kind = null, issuer = null, statuses = ['published'], limit = 200 } = {}) {++ const rows = await db.many(`SELECT * FROM inventory_definitions WHERE status = ANY($1::text[])++ AND ($2::text IS NULL OR kind = $2) AND ($3::text IS NULL OR issuer = $3) ORDER BY kind, name LIMIT $4`,++ [statuses, kind, issuer, Math.max(1, Math.min(500, Number(limit) || 200))]);++ const aliases = await aliasesOf(rows.map((r) => r.id));++ return rows.map((r) => definitionRow(r, aliases.get(r.id) || []));++ }++++ function checkDefinitionBody(b, { partial = false } = {}) {++ const has = (k) => b[k] !== undefined;++ if (!partial || has('name')) if (typeof b.name !== 'string' || !b.name.trim() || b.name.length > 80) fail(422, 'inventory.bad_name', 'name is 1 to 80 characters');++ if (has('description') && b.description !== null && (typeof b.description !== 'string' || b.description.length > 500)) fail(422, 'inventory.bad_description', 'description is at most 500 characters');++ if (!partial || has('art')) {++ const a = b.art;++ if (!a || typeof a !== 'object' || Array.isArray(a) || !Object.keys(a).length) fail(422, 'inventory.bad_art', 'art names a Media object (media_id), a renderer token or an emoji');++ for (const k of Object.keys(a)) if (!['media_id', 'token', 'emoji'].includes(k)) fail(422, 'inventory.bad_art', `art has no field ${k}`);++ if (a.media_id !== undefined && !/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(a.media_id))) fail(422, 'inventory.bad_art', 'art.media_id is a med_ id');++ if (a.token !== undefined && (typeof a.token !== 'string' || !a.token || a.token.length > 80)) fail(422, 'inventory.bad_art', 'art.token is 1 to 80 characters');++ if (a.emoji !== undefined && (typeof a.emoji !== 'string' || a.emoji.length > 16)) fail(422, 'inventory.bad_art', 'art.emoji is at most 16 characters');++ }++ if (!partial || has('rarity')) if (!RARITY.includes(b.rarity)) fail(422, 'inventory.bad_rarity', `rarity is one of ${RARITY.join(', ')}`);++ if (has('supply_cap') && b.supply_cap !== null && !(Number.isInteger(b.supply_cap) && b.supply_cap > 0)) fail(422, 'inventory.bad_supply', 'supply_cap is a positive whole number or null');++ if (has('aliases') && (!Array.isArray(b.aliases) || b.aliases.length > 8 || b.aliases.some((x) => !/^[a-z0-9][a-z0-9_.-]{0,63}$/.test(String(x))))) fail(422, 'inventory.bad_alias', 'aliases: up to 8 lower-case ids');++ }++++ /** A definition in the kind's namespace, by the kind's issuer (services and apps; creators open with the Workshop). */++ async function createDefinition(requester, b, { status = null } = {}) {++ if (!b || typeof b !== 'object') fail(400, 'request.bad_body', 'send inventory.definition-request@1');++ const kind = await getKind(b.kind);++ if (!kind) fail(422, 'inventory.unknown_kind', `no kind ${b.kind}`);++ if (kind.issuer !== requester) fail(403, 'inventory.not_issuer', `only ${kind.issuer} defines ${kind.id} items (community items open with the Workshop, ADR-054 §6)`);++ checkDefinitionBody(b);++ await checkAttributes(kind.id, b.attributes || {});++ const id = s.newId('itd');++ const now = s.iso();++ const st = status || (b.publish ? 'published' : 'draft');++ return await s.tx(async () => {++ await db.exec(`INSERT INTO inventory_definitions (id, kind, name, description, art, rarity, attributes, issuer, supply_cap, status, created_at, updated_at, published_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11, $12)`,++ [id, kind.id, b.name.trim(), b.description || null, JSON.stringify(b.art), b.rarity, JSON.stringify(b.attributes || {}), requester, b.supply_cap || null, st, now, st === 'published' ? now : null]);++ for (const alias of b.aliases || []) {++ const taken = await db.maybe('SELECT definition_id FROM inventory_definition_aliases WHERE issuer = $1 AND alias = $2', [requester, alias]);++ if (taken) fail(409, 'inventory.alias_taken', `${alias} already names ${taken.definition_id}`);++ await db.exec('INSERT INTO inventory_definition_aliases (issuer, alias, definition_id) VALUES ($1, $2, $3)', [requester, alias, id]);++ }++ await ledger('defined', { definition: id, actor: requester, detail: { kind: kind.id, status: st } });++ if (st === 'published') await emit('inventory.definition.published', { subject: { type: 'item_definition', id }, visibility: 'public', actor: requester, payload: { definition_id: id, kind: kind.id, name: b.name.trim(), rarity: b.rarity, issuer: requester } });++ return await getDefinition(id);++ });++ }++++ /** Edit a definition its issuer owns: name, description and art any time; rarity, attributes and cap until it is published. */++ async function updateDefinition(requester, id, b) {++ if (!b || typeof b !== 'object') fail(400, 'request.bad_body', 'send the fields to change');++ return await s.tx(async () => {++ const cur = await db.maybe('SELECT * FROM inventory_definitions WHERE id = $1 FOR UPDATE', [String(id || '')]);++ if (!cur) fail(404, 'inventory.definition_not_found', 'no such definition');++ if (cur.issuer !== requester) fail(403, 'inventory.not_issuer', 'only its issuer edits a definition');++ checkDefinitionBody(b, { partial: true });++ const locked = cur.status === 'published' || cur.status === 'retired';++ for (const k of ['rarity', 'attributes', 'supply_cap', 'kind']) if (locked && b[k] !== undefined) fail(409, 'inventory.published', `${k} cannot change once the item is published (its owners rely on it)`);++ if (b.attributes !== undefined) await checkAttributes(cur.kind, b.attributes);++ let status = cur.status;++ if (b.status === 'retired') status = 'retired';++ else if (b.publish && cur.status === 'draft') status = 'published';++ else if (b.status !== undefined && b.status !== cur.status && b.status !== 'retired') fail(422, 'inventory.bad_status', 'status: retired, or publish: true on a draft');++ const now = s.iso();++ await db.exec(`UPDATE inventory_definitions SET name = $2, description = $3, art = $4, rarity = $5, attributes = $6, supply_cap = $7, status = $8,++ updated_at = $9, published_at = COALESCE(published_at, CASE WHEN $8 = 'published' THEN $9 END) WHERE id = $1`,++ [cur.id, b.name !== undefined ? b.name.trim() : cur.name, b.description !== undefined ? b.description : cur.description,++ JSON.stringify(b.art !== undefined ? b.art : cur.art), b.rarity !== undefined ? b.rarity : cur.rarity,++ JSON.stringify(b.attributes !== undefined ? b.attributes : cur.attributes), b.supply_cap !== undefined ? b.supply_cap : cur.supply_cap, status, now]);++ await ledger(status !== cur.status ? status : 'edited', { definition: cur.id, actor: requester });++ if (status === 'published' && cur.status !== 'published') {++ await emit('inventory.definition.published', { subject: { type: 'item_definition', id: cur.id }, visibility: 'public', actor: requester, payload: { definition_id: cur.id, kind: cur.kind, name: b.name !== undefined ? b.name.trim() : cur.name, rarity: b.rarity || cur.rarity, issuer: cur.issuer } });++ }++ return await getDefinition(cur.id);++ });++ }++++ // ── Grants ────────────────────────────────────────────++ /**++ * One instance of a published definition the requester issued, to a person. → { instance, created }.++ * origin 'migrated' is the migration's alone (scripts/import-live.js), never the API's.++ */++ async function grant(requester, b, { allowMigrated = false } = {}) {++ if (!b || typeof b !== 'object') fail(400, 'request.bad_body', 'send inventory.grant-request@1');++ if (!DEF_RE.test(String(b.definition_id || ''))) fail(422, 'inventory.bad_definition', 'definition_id is an itd_ id');++ if (!SUBJECT_RE.test(String(b.subject || ''))) fail(422, 'inventory.bad_subject', 'items go to people: a usr_ subject');++ const key = String(b.idempotency_key || '');++ if (key.length < 8 || key.length > 128) fail(422, 'inventory.bad_key', 'idempotency_key is 8 to 128 characters');++ const origin = b.origin || 'granted';++ if (!['granted', 'earned'].concat(allowMigrated ? ['migrated'] : []).includes(origin)) fail(422, 'inventory.bad_origin', 'origin is granted or earned (nothing is bought or traded, ADR-054 §5)');++ if (b.reason !== undefined && (typeof b.reason !== 'string' || b.reason.length > 200)) fail(422, 'inventory.bad_reason', 'reason is at most 200 characters');++ return await s.tx(async () => {++ const prior = await db.maybe('SELECT instance_id FROM inventory_grants WHERE issuer = $1 AND idempotency_key = $2', [requester, key]);++ if (prior) return { instance: instanceRow(await db.maybe('SELECT * FROM inventory_instances WHERE id = $1', [prior.instance_id])), created: false };++ const def = await db.maybe('SELECT * FROM inventory_definitions WHERE id = $1 FOR UPDATE', [b.definition_id]);++ if (!def) fail(404, 'inventory.definition_not_found', 'no such definition');++ if (def.issuer !== requester) fail(403, 'inventory.not_issuer', `only ${def.issuer} grants this item`);++ if (def.status !== 'published') fail(409, 'inventory.not_published', `the item is ${def.status}`);++ const kind = await getKind(def.kind);++ if (b.attributes !== undefined) await checkAttributes(kind.id, { ...def.attributes, ...b.attributes });++ if (!kind.stackable) {++ const have = await db.maybe("SELECT * FROM inventory_instances WHERE definition_id = $1 AND owner_subject = $2 AND state = 'owned' LIMIT 1", [def.id, b.subject]);++ if (have) {++ await db.exec('INSERT INTO inventory_grants (issuer, idempotency_key, instance_id, created_at) VALUES ($1, $2, $3, $4)', [requester, key, have.id, s.iso()]);++ return { instance: instanceRow(have), created: false };++ }++ }++ if (def.supply_cap != null && Number(def.supply_issued) >= Number(def.supply_cap)) fail(409, 'inventory.supply_exhausted', `all ${def.supply_cap} have been given`);++ const serial = def.supply_cap != null ? Number(def.supply_issued) + 1 : null;++ const id = s.newId('inv');++ const now = s.iso();++ await db.exec('UPDATE inventory_definitions SET supply_issued = supply_issued + 1 WHERE id = $1', [def.id]);++ await db.exec(`INSERT INTO inventory_instances (id, definition_id, owner_subject, origin, state, attributes, serial, acquired_at, updated_at)++ VALUES ($1, $2, $3, $4, 'owned', $5, $6, $7, $7)`, [id, def.id, b.subject, origin, JSON.stringify(b.attributes || {}), serial, allowMigrated && b.acquired_at ? b.acquired_at : now]);++ await db.exec('INSERT INTO inventory_grants (issuer, idempotency_key, instance_id, created_at) VALUES ($1, $2, $3, $4)', [requester, key, id, now]);++ await ledger(origin, { owner: b.subject, instance: id, definition: def.id, actor: requester, reason: b.reason || null });++ await emit('inventory.item.granted', { subject: { type: 'user', id: b.subject }, visibility: 'subject', actor: requester,++ payload: { instance_id: id, definition_id: def.id, kind: def.kind, owner: b.subject, origin, issuer: requester } });++ return { instance: instanceRow(await db.maybe('SELECT * FROM inventory_instances WHERE id = $1', [id])), created: true };++ });++ }++++ /** An issuer uses up (consume) or takes back (revoke) an instance of an item it issued. */++ async function consume(requester, instanceId, { revoke = false, reason = null } = {}) {++ if (!INST_RE.test(String(instanceId || ''))) fail(404, 'inventory.instance_not_found', 'no such instance');++ return await s.tx(async () => {++ const inst = await db.maybe('SELECT i.*, d.issuer FROM inventory_instances i JOIN inventory_definitions d ON d.id = i.definition_id WHERE i.id = $1 FOR UPDATE OF i', [instanceId]);++ if (!inst) fail(404, 'inventory.instance_not_found', 'no such instance');++ if (inst.issuer !== requester) fail(403, 'inventory.not_issuer', `only ${inst.issuer} consumes or revokes this item`);++ if (inst.state !== 'owned') fail(409, 'inventory.not_owned', `the instance is ${inst.state}`);++ const state = revoke ? 'revoked' : 'consumed';++ await db.exec('UPDATE inventory_instances SET state = $2, updated_at = $3 WHERE id = $1', [inst.id, state, s.iso()]);++ await db.exec('DELETE FROM inventory_equipped WHERE instance_id = $1', [inst.id]);++ await ledger(state, { owner: inst.owner_subject, instance: inst.id, definition: inst.definition_id, actor: requester, reason });++ const payload = revoke++ ? { instance_id: inst.id, definition_id: inst.definition_id, owner: inst.owner_subject, reason: String(reason || 'revoked by its issuer').slice(0, 200) }++ : { instance_id: inst.id, definition_id: inst.definition_id, owner: inst.owner_subject, issuer: requester };++ await emit(revoke ? 'inventory.item.revoked' : 'inventory.item.consumed', { subject: { type: 'user', id: inst.owner_subject }, visibility: 'subject', actor: requester, payload });++ return instanceRow(await db.maybe('SELECT * FROM inventory_instances WHERE id = $1', [inst.id]));++ });++ }++++ // ── Reading ───────────────────────────────────────────++ /** inventory.inventory@1. own: every state; public: owned instances of published or retired items. */++ async function inventory(subject, { own = false, cursor = null, limit = 100, kind = null } = {}) {++ if (!SUBJECT_RE.test(String(subject || ''))) fail(404, 'inventory.unknown_subject', 'a usr_ subject');++ const after = decodeCursor(cursor);++ const n = Math.max(1, Math.min(200, Number(limit) || 100));++ const rows = await db.many(`SELECT i.* FROM inventory_instances i JOIN inventory_definitions d ON d.id = i.definition_id++ WHERE i.owner_subject = $1 AND ($2::boolean OR (i.state = 'owned' AND d.status IN ('published', 'retired')))++ AND ($3::text IS NULL OR d.kind = $3)++ AND ($4::text IS NULL OR (i.acquired_at, i.id) < ($4, $5))++ ORDER BY i.acquired_at DESC, i.id DESC LIMIT $6`, [subject, own, kind, after ? after.at : null, after ? after.id : null, n + 1]);++ const page = rows.slice(0, n);++ const defIds = [...new Set(page.map((r) => r.definition_id))];++ const defs = defIds.length ? await db.many('SELECT * FROM inventory_definitions WHERE id = ANY($1::text[])', [defIds]) : [];++ const aliases = await aliasesOf(defIds);++ return {++ subject,++ instances: page.map(instanceRow),++ definitions: Object.fromEntries(defs.map((d) => [d.id, definitionRow(d, aliases.get(d.id) || [])])),++ next_cursor: rows.length > n ? encodeCursor(page[page.length - 1]) : null,++ };++ }++++ /** Many people's equipped sets (inventory.equipped@1 each), in one query. Unknown or empty subjects get {}. */++ async function equipped(subjects) {++ const list = [...new Set((subjects || []).map(String))].filter((x) => SUBJECT_RE.test(x)).slice(0, 100);++ if (!list.length) return [];++ const rows = await db.many(`SELECT e.owner_subject, e.kind, e.slot, e.instance_id, e.updated_at, i.definition_id, d.art->>'token' AS token++ FROM inventory_equipped e JOIN inventory_instances i ON i.id = e.instance_id JOIN inventory_definitions d ON d.id = i.definition_id++ WHERE e.owner_subject = ANY($1::text[]) AND i.state = 'owned' ORDER BY e.owner_subject, e.kind, e.slot`, [list]);++ const by = new Map(list.map((x) => [x, { subject: x, slots: {}, updated_at: null }]));++ for (const r of rows) {++ const e = by.get(r.owner_subject);++ e.slots[`${r.kind}:${r.slot}`] = { instance_id: r.instance_id, definition_id: r.definition_id, ...(r.token ? { token: r.token } : {}) };++ if (!e.updated_at || r.updated_at > e.updated_at) e.updated_at = r.updated_at;++ }++ const fallback = s.iso();++ return [...by.values()].map((e) => ({ ...e, updated_at: e.updated_at || fallback }));++ }++++ /** Equip an owned instance in a slot of its kind, or clear the slot (instance_id null). → inventory.equipped@1. */++ async function equip(subject, actor, b) {++ if (!SUBJECT_RE.test(String(subject || ''))) fail(403, 'inventory.no_subject', 'equipping is for a person');++ if (!b || typeof b !== 'object') fail(400, 'request.bad_body', 'send inventory.equip-request@1');++ const kind = await getKind(b.kind);++ if (!kind) fail(422, 'inventory.unknown_kind', `no kind ${b.kind}`);++ if (!kind.slots.includes(b.slot)) fail(422, 'inventory.bad_slot', `${kind.id} has the slots ${kind.slots.join(', ') || '(none: not equippable)'}`);++ await s.tx(async () => {++ if (b.instance_id === null) {++ const cur = await db.maybe('DELETE FROM inventory_equipped WHERE owner_subject = $1 AND kind = $2 AND slot = $3 RETURNING instance_id', [subject, kind.id, b.slot]);++ if (cur) {++ await ledger('unequipped', { owner: subject, instance: cur.instance_id, actor, detail: { kind: kind.id, slot: b.slot } });++ await emit('inventory.item.unequipped', { subject: { type: 'user', id: subject }, visibility: 'public', actor, payload: { kind: kind.id, slot: b.slot, owner: subject } });++ }++ return;++ }++ const inst = await db.maybe(`SELECT i.*, d.kind FROM inventory_instances i JOIN inventory_definitions d ON d.id = i.definition_id++ WHERE i.id = $1 AND i.owner_subject = $2 AND i.state = 'owned'`, [String(b.instance_id || ''), subject]);++ if (!inst) fail(409, 'inventory.not_owned', 'you do not own that item (or it was used up)');++ if (inst.kind !== kind.id) fail(422, 'inventory.wrong_kind', `that item is a ${inst.kind}, not a ${kind.id}`);++ const now = s.iso();++ await db.exec(`INSERT INTO inventory_equipped (owner_subject, kind, slot, instance_id, updated_at) VALUES ($1, $2, $3, $4, $5)++ ON CONFLICT (owner_subject, kind, slot) DO UPDATE SET instance_id = excluded.instance_id, updated_at = excluded.updated_at`, [subject, kind.id, b.slot, inst.id, now]);++ await ledger('equipped', { owner: subject, instance: inst.id, definition: inst.definition_id, actor, detail: { kind: kind.id, slot: b.slot } });++ await emit('inventory.item.equipped', { subject: { type: 'user', id: subject }, visibility: 'public', actor, payload: { instance_id: inst.id, definition_id: inst.definition_id, kind: kind.id, slot: b.slot, owner: subject } });++ });++ return (await equipped([subject]))[0];++ }++++ return {++ listKinds, getKind, upsertKind, listDefinitions, getDefinition, byAlias, createDefinition, updateDefinition,++ grant, consume, inventory, equipped, equip, ledger,++ };++}++++module.exports = { createInventory, InventoryError, actorOf, SUBJECT_RE, DEF_RE, INST_RE, RARITY };nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++++/**++ * Truthful readiness for GET /api/ready (openvibe-shared/ready, Track O).++ *++ * db required a real round trip to OpenVibe.Inventory's PostgreSQL store++ * network_jwks optional the Network signing key is loaded; without it nobody can sign in or call the API++ * oauth_client optional OV_OAUTH_CLIENT_SECRET is set (sign-in needs it)++ * valkey optional shared per-caller limit counters++ *++ * The product adds its own required checks here (a queue, a model provider, a worker) with a real round trip,++ * so /api/ready never reads green for something that is not working.++ */++const { jwksStatus } = require('openvibe-sdk/auth');++const { createReadiness } = require('openvibe-shared/ready');++++function createServiceReadiness({ s, config, valkey = null, release = null }) {++ return createReadiness({++ service: 'inventory',++ release,++ checks: [++ {++ name: 'db', required: true,++ check: async () => {++ const r = await s.db.ready();++ return r.ok ? { ok: true, detail: r.detail } : r.error;++ },++ },++ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-caller limits count in this process only' }) },++ {++ name: 'network_jwks', required: false,++ check: () => {++ const statuses = jwksStatus();++ if (!statuses.length) return 'no JWKS clients (misconfigured)';++ const first = statuses[0];++ if (!first.ready) return 'Network signing key not loaded yet: sign-in and the API are unavailable';++ return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };++ },++ },++ { name: 'oauth_client', required: false, check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete') },++ ],++ });++}++++module.exports = { createServiceReadiness };nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++++/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++/** Tagged template: interpolations are escaped unless wrapped in raw(). Arrays are joined. */++class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }++const raw = (s) => new Raw(s);++function html(strings, ...values) {++ let out = strings[0];++ values.forEach((v, i) => {++ out += render(v) + strings[i + 1];++ });++ return raw(out);++}++function render(v) {++ if (v == null || v === false) return '';++ if (v instanceof Raw) return v.s;++ if (Array.isArray(v)) return v.map(render).join('');++ return esc(v);++}++++const code = (s) => html`<code>${s}</code>`;++const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');++++/** A <table> from headers and rows of cells (cells are already html or plain values). */++function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {++ if (!rows.length) return html`<p class="muted">${empty}</p>`;++ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;++}++++/** A problem as returned by Network, Events or Media — shown as it came, never softened. */++function problemBox(p, { title = 'The request failed' } = {}) {++ if (!p) return '';++ return html`<div class="problem" role="alert"><strong>${title}</strong>++<p>${p.detail || 'No detail was given.'}</p>++<p class="muted small">HTTP ${p.status || '—'} · <code>${p.code || 'unknown'}</code>${p.requestId ? html` · request <code>${p.requestId}</code>` : ''}</p></div>`;++}++++const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;++const csrfField = (token) => html`<input type="hidden" name="csrf" value="${token}">`;++const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;++++module.exports = { esc, html, raw, Raw, render, code, time, table, problemBox, notice, csrfField, badge };nnew file mode 100644@@@ -0,0 +1,125 @@++'use strict';++++/**++ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript:++ * - <head>: title, description, canonical, robots (pages are noindex unless they say otherwise), Open Graph/Twitter,++ * JSON-LD (openvibe-shared/seo), plus this service's extras: the shared app icon, the stylesheet, the boost marker++ * - the OpenVibe Frame: theme-loader, web-runtime, navbar and footer scripts (the shell boots the++ * navbar; the footer is initialised below), a <noscript> navigation bar and the server-rendered footer++ * - the product's own script only where a page offers an in-browser convenience++ */++const crypto = require('crypto');++const ovServe = require('openvibe-shared/serve');++const fs = require('fs');++const path = require('path');++const appIcon = require('openvibe-shared/app-icon');++const frame = require('openvibe-shared/frame');++const shell = require('openvibe-shared/shell');++const cache = require('openvibe-shared/cache-policy');++const { html, raw, esc } = require('./html');++++const NETWORK_URL = 'https://openvibe.network';++const SITE_NAME = 'OpenVibe.Inventory';++const TAGLINE = 'Your items, on every OpenVibe site.';++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');++// The product's own navigation: one entry per public page it serves.++const NAV = [++ { label: 'Every item', href: '/items' },++ { label: 'Your inventory', href: '/me' },++ { label: 'What shipped', href: '/updates' },++];++++const hashes = new Map();++function assetVersion(rel) {++ if (hashes.has(rel)) return hashes.get(rel);++ let v = 'dev';++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }++ hashes.set(rel, v);++ return v;++}++const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;++++// The deployed release (app.js sets it from openvibe-shared/release): openvibe-shared/boost swaps a page in place only++// between pages of the same release, and does a normal load across a deploy.++let RELEASE = 'dev';++function setRelease(id) { if (id) RELEASE = String(id); }++++/**++ * o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],++ * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names]++ */++function renderPage(o) {++ const viewer = o.viewer || { kind: 'anonymous' };++ const signedIn = viewer.kind === 'user';++ const pathNow = o.path || '/';++ const loginNext = encodeURIComponent(pathNow);++ const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;++ const nav = {++ service: 'inventory',++ apiBase: NETWORK_URL,++ links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),++ history: { type: 'page', title: o.title || SITE_NAME },++ silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,++ sessionUrl: '/auth/me',++ loginUrl: '/auth/login?next={path}', // filled from the current page (boost moves between pages)++ logoutUrl: '/auth/logout?next={path}', // Sign out in the shared navbar ends this site's session too++ notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)++ };++ const footer = { service: 'inventory', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };++ const account = signedIn++ ? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`++ : html`<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;++ const crumbs = (o.crumbs || []).length++ ? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`++ : '';++ const scripts = (o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`);++ return shell.page({++ name: SITE_NAME,++ lang: 'en',++ title: o.title || `${SITE_NAME}: ${TAGLINE}`,++ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',++ siteName: SITE_NAME,++ description: o.description || `${SITE_NAME} — ${TAGLINE}`,++ canonical,++ robots: o.index ? 'index, follow' : 'noindex, nofollow',++ jsonLd: (o.jsonLd || []).filter(Boolean),++ home: '/',++ navLinks: NAV,++ navbar: nav,++ footer,++ head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">++${appIcon.headTags({ site: 'inventory' })}++<link rel="stylesheet" href="${asset('css/app.css')}">++${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}++${render(scripts)}++<meta name="ov-boost" content="inventory@${esc(RELEASE)}">++<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,++ body: `<a class="skip" href="#main">Skip to content</a>++<div id="navbar-mount"></div>++<noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>++<main id="main" class="page">++${render(crumbs)}++${render(o.body)}++${o.path === '/' ? frame.shipped({ service: 'inventory', title: `Recently shipped on ${SITE_NAME}` }) : ''}++</main>++<script>++window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};++document.addEventListener('DOMContentLoaded', function () {++ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }++});++</script>`,++ });++}++++function render(v) { return require('./html').render(v); }++++/** Send a page; a page rendered for a signed-in person is private and never cached. */++function send(res, status, o) {++ // A page rendered for a signed-in person names them in the account bar: never shared caches.++ const personal = o.viewer && o.viewer.kind === 'user';++ if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? o.cache : cache.htmlHeaders({ private: true }));++ res.set('Vary', 'Cookie');++ res.status(status).type('html').send(renderPage(o));++}++++module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL, NAV, raw, html };nnew file mode 100644@@@ -0,0 +1,80 @@++'use strict';++++/**++ * A deliberately small Markdown renderer for text a caller supplied or a model wrote. Everything is escaped first; only++ * headings, paragraphs, lists, fenced code, tables, bold, italics, inline code and links are recognised. Links are kept++ * only when http(s) or relative, and never vouched for: rel="nofollow ugc noopener", opened in a new tab.++ */++const { esc } = require('./html');++++function inline(s) {++ let out = esc(s);++ out = out.replace(/`([^`]+)`/g, '<code>$1</code>');++ out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');++ out = out.replace(/(^|[\s(])\*([^*\s][^*]*)\*(?=[\s).,;:!?]|$)/g, '$1<em>$2</em>');++ out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, text, href) => {++ const h = href.replace(/&/g, '&');++ if (/^https?:\/\//.test(h)) return `<a href="${esc(h)}" rel="nofollow ugc noopener" target="_blank">${text}</a>`;++ if (/^[#./a-zA-Z0-9]/.test(h) && !/^[a-z]+:/i.test(h)) return `<a href="${esc(h)}">${text}</a>`;++ return text;++ });++ // Bare links the model wrote out, outside an <a> already made above.++ out = out.replace(/(^|[\s(])(https?:\/\/[^\s<)"']+[^\s<)"'.,;:!?])/g, (m, pre, url) => `${pre}<a href="${url}" rel="nofollow ugc noopener" target="_blank">${url}</a>`);++ return out;++}++++const cells = (line) => line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());++const isSeparator = (line) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line);++++function markdown(src, { headingOffset = 1 } = {}) {++ const lines = String(src || '').replace(/\r\n/g, '\n').split('\n');++ const out = [];++ let para = [];++ let list = null; // { type: 'ul' | 'ol', items: [] }++ let fence = null;++ const flushPara = () => { if (para.length) { out.push(`<p>${inline(para.join(' '))}</p>`); para = []; } };++ const flushList = () => { if (list) { out.push(`<${list.type}>${list.items.map((i) => `<li>${inline(i)}</li>`).join('')}</${list.type}>`); list = null; } };++ for (let i = 0; i < lines.length; i++) {++ const line = lines[i];++ if (fence) {++ if (/^```/.test(line)) { out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(line);++ continue;++ }++ if (/^```/.test(line)) { flushPara(); flushList(); fence = []; continue; }++ // A table: a header row, a separator row, then rows while lines start with |.++ if (/^\s*\|/.test(line) && i + 1 < lines.length && isSeparator(lines[i + 1])) {++ flushPara(); flushList();++ const head = cells(line);++ const rows = [];++ i += 2;++ while (i < lines.length && /^\s*\|/.test(lines[i])) { rows.push(cells(lines[i])); i++; }++ i--;++ out.push(`<div class="table-wrap"><table><thead><tr>${head.map((h) => `<th scope="col">${inline(h)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`);++ continue;++ }++ const h = line.match(/^(#{1,6})\s+(.*)$/);++ if (h) {++ flushPara(); flushList();++ const level = Math.min(6, h[1].length + headingOffset);++ out.push(`<h${level}>${inline(h[2])}</h${level}>`);++ continue;++ }++ const li = line.match(/^\s*(?:[-*]|(\d+)\.)\s+(.*)$/);++ if (li) {++ flushPara();++ const type = li[1] ? 'ol' : 'ul';++ if (!list || list.type !== type) { flushList(); list = { type, items: [] }; }++ list.items.push(li[2]);++ continue;++ }++ if (!line.trim()) { flushPara(); flushList(); continue; }++ if (list && /^\s{2,}\S/.test(line)) { list.items[list.items.length - 1] += ` ${line.trim()}`; continue; }++ flushList();++ para.push(line.trim());++ }++ if (fence) out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`);++ flushPara(); flushList();++ return out.join('\n');++}++++module.exports = { markdown, inline };nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++/**++ * ADR-033 / ADR-054 §7: Inventory's part of an account export and of an account deletion, through the signed loopback++ * route POST /internal/events with a stand-in Network. Alice's items, equipped set and history are exported; her++ * deletion removes her items and equipped set, keeps the ledger rows without her, keeps the supply count true, leaves++ * Bob's items alone and confirms once.++ */++const assert = require('assert');++const http = require('http');++const { createNetworkSender } = require('openvibe-sdk/account-data');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const { boot, check, done } = require('./helpers/boot');++++const SECRET = `whsec_${'fixture'.repeat(6)}`;++++async function startNetworkStub() {++ const calls = [];++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_inventory', token_type: 'Bearer', expires_in: 300 });++ calls.push({ url: req.url, auth: req.headers.authorization, body: JSON.parse(Buffer.concat(chunks).toString() || 'null') });++ return json(201, {});++ });++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };++}++++(async () => {++ const stub = await startNetworkStub();++ const t = await boot({ env: { INVENTORY_EVENTS_SECRET: SECRET }, accountSend: createNetworkSender({ networkInternalUrl: stub.url, clientId: 'inventory', clientSecret: 'inventory-secret' }) });++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const db = t.ctx.s.db;++ const count = async (sql, args) => Number(await db.value(sql, args));++ const deliver = async (event, { secret = SECRET, headers = {} } = {}) => {++ const body = JSON.stringify({ event, seq: 1 });++ const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } });++ return { status: res.status, json: await res.json().catch(() => null) };++ };++ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), payload });++ const inv = t.ctx.inv;++ let crown;++++ try {++ await check('alice and bob own items; alice wears one', async () => {++ crown = await inv.byAlias('service:live', 'hat_crown');++ const rainbow = await inv.byAlias('service:live', 'fx_rainbow');++ for (const [who, key] of [[alice, 'a1'], [bob, 'b1']]) await inv.grant('service:live', { definition_id: crown.id, subject: who.subject, idempotency_key: `test-0000-${key}` });++ const r = await inv.grant('service:live', { definition_id: rainbow.id, subject: alice.subject, idempotency_key: 'test-0000-a2' });++ await inv.equip(alice.subject, `user:${alice.subject}`, { kind: 'live.name_effect', slot: 'name_effect', instance_id: r.instance.id });++ });++++ await check('the export carries her items, equipped set and history, and nothing of bob\'s', async () => {++ const r = await deliver(ev('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX1', subject: alice.subject }));++ assert.deepStrictEqual([r.status, r.json && r.json.outcome], [200, 'exported'], JSON.stringify(r.json));++ const part = stub.calls.find((c) => c.url === '/internal/account-exports/exp_01JZ0000000000000000000EX1/parts');++ assert.strictEqual(part.auth, 'Bearer tok_inventory');++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['equipped.json', 'history.json', 'items.json']);++ assert.strictEqual(part.body.files.find((f) => f.name === 'items.json').content.length, 2);++ assert.ok(!JSON.stringify(part.body).includes(bob.subject));++ });++++ await check('the deletion removes her items and equipped set, keeps the ledger without her, and confirms once', async () => {++ const event = ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DE1', subject: alice.subject });++ const r = await deliver(event);++ assert.deepStrictEqual([r.status, r.json && r.json.outcome], [200, 'erased'], JSON.stringify(r.json));++ assert.strictEqual(await count('SELECT count(*) FROM inventory_instances WHERE owner_subject = $1', [alice.subject]), 0);++ assert.strictEqual(await count('SELECT count(*) FROM inventory_equipped WHERE owner_subject = $1', [alice.subject]), 0);++ assert.strictEqual(await count('SELECT count(*) FROM inventory_ledger WHERE owner_subject = $1 OR actor = $2', [alice.subject, `user:${alice.subject}`]), 0);++ assert.ok(await count('SELECT count(*) FROM inventory_ledger WHERE owner_subject IS NULL') >= 3, 'her history stays, without her');++ assert.strictEqual(await count('SELECT count(*) FROM inventory_instances WHERE owner_subject = $1', [bob.subject]), 1, 'bob keeps his');++ assert.strictEqual(await count('SELECT supply_issued FROM inventory_definitions WHERE id = $1', [crown.id]), 2, 'the supply count stays true');++ const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE1/confirmations');++ assert.strictEqual(conf.length, 1);++ assert.deepStrictEqual([conf[0].body.erased.inventory_instances, conf[0].body.erased.inventory_equipped], [2, 1]);++ assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');++ assert.strictEqual(stub.calls.filter((c) => c.url.includes('/confirmations')).length, 1);++ });++++ await check('the route refuses a bad signature and a request that came through a proxy', async () => {++ const event = ev('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX2', subject: alice.subject });++ assert.strictEqual((await deliver(event, { secret: `whsec_${'mismatch'.repeat(5)}` })).status, 401);++ assert.strictEqual((await deliver(event, { headers: { 'X-Forwarded-For': '203.0.113.9' } })).status, 403);++ });++ } finally {++ await t.close();++ await stub.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++/**++ * The one cache rule the service serves assets with (openvibe-shared/cache-policy, plan T11): a URL++ * whose ?v= is the asset's current hash is immutable for a year, because those bytes can never++ * change under the same URL; a wrong or missing ?v= may change at any moment, so it gets the++ * short public window with a day of stale-while-revalidate. The values are pinned here against++ * the module, not re-stated: the service never picks its own numbers.++ */++const assert = require('assert');++const cache = require('openvibe-shared/cache-policy');++const { assetVersion } = require('../server/render/layout');++const { boot, check, done } = require('./helpers/boot');++++const ASSET = 'css/app.css';++const SHORT = 'public, max-age=300, stale-while-revalidate=86400';++++(async () => {++ const t = await boot();++ try {++ await check('the current ?v=<assetVersion> is immutable for a year', async () => {++ const r = await t.get(`/${ASSET}?v=${assetVersion(ASSET)}`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=31536000, immutable');++ });++++ await check('a wrong-but-hex ?v= is never pinned', async () => {++ const r = await t.get(`/${ASSET}?v=deadbeefdeadbeef`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), SHORT);++ });++++ await check('no ?v= gets the short public window', async () => {++ const r = await t.get(`/${ASSET}`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), SHORT);++ });++++ await check('those values are the module\'s, not the service\'s own', () => {++ assert.strictEqual(cache.assetHeaders(ASSET, { hashed: true }), 'public, max-age=31536000, immutable');++ assert.strictEqual(cache.assetHeaders(ASSET, { hashed: false }), SHORT);++ assert.strictEqual(cache.htmlHeaders({ private: true }), 'private, no-store');++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,148 @@++'use strict';++/**++ * The Network signing key is fetched and verified through openvibe-sdk/auth (plan T0/T1). The++ * SDK keeps one client per URL: the last good keys through outages, exponential backoff, a++ * rotation honoured at once, unknown-kid floods throttled. The service is a thin shim around it:++ * the boot reads the key and verifies a session/app token against the URL,++ * a JWKS outage with cached keys still verifies,++ * readiness (network_jwks) shows the cache state.++ */++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const { boot, check, done } = require('./helpers/boot');++++function jwksServer({ doc = null, fail = null } = {}) {++ const seen = [];++ const srv = http.createServer((req, res) => {++ seen.push(req.url);++ if (fail) { res.writeHead(503); return res.end('down'); }++ res.writeHead(200, { 'Content-Type': 'application/json' });++ res.end(JSON.stringify(doc));++ });++ return new Promise((resolve) => {++ srv.listen(0, '127.0.0.1', () => resolve({++ url: `http://127.0.0.1:${srv.address().port}`,++ close: () => new Promise((r) => srv.close(r)),++ seen,++ setDoc: (d) => { doc = d; },++ setFail: (f) => { fail = f; },++ }));++ });++}++++function signJwt(claims, privateKey, { kid = null } = {}) {++ const header = { alg: 'RS256', typ: 'JWT', ...(kid ? { kid } : {}) };++ const input = `${Buffer.from(JSON.stringify(header)).toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}`;++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;++}++++function pem(k) { return k.export({ type: 'spki', format: 'pem' }); }++function jwk(k, kid) { return { ...k.export({ format: 'jwk' }), use: 'sig', alg: 'RS256', kid }; }++++(async () => {++ await check('a token signed by a key from a stub JWKS verifies', async () => {++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ const jwks = await jwksServer({ doc: { keys: [jwk(publicKey, 'k1')] } });++ const { createKeyStore, jwksUrl } = require('../server/auth/keys');++ const config = { networkInternalUrl: jwks.url, networkUrl: jwks.url, networkIssuer: 'https://openvibe.network' };++ const keys = createKeyStore({ config, log: console });++ const url = jwksUrl(config);++ await keys.ensure();++ const now = Math.floor(Date.now() / 1000);++ const token = signJwt({ iss: config.networkIssuer, sub: 'usr_demo', username: 'demo', aud: ['openvibe.network'], iat: now, exp: now + 300 }, privateKey, { kid: 'k1' });++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: 'openvibe.network' });++ assert.strictEqual(v.ok, true, `verify should succeed: ${v.reason || ''}`);++ assert.strictEqual(v.claims.username, 'demo');++ assert.ok(keys.get(), 'the cached PEM is exposed to the contracts guard');++ assert.deepStrictEqual(jwks.seen, ['/api/.well-known/jwks']);++ await jwks.close();++ });++++ // A rotation publishes two keys: verification must use the one the token names, not the first.++ await check('service tokens get the key their kid names (a rotation keeps two keys)', async () => {++ const a = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ const b = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ const jwks = await jwksServer({ doc: { keys: [jwk(a.publicKey, 'old'), jwk(b.publicKey, 'new')] } });++ const { createKeyStore } = require('../server/auth/keys');++ const config = { networkInternalUrl: jwks.url, networkUrl: jwks.url, networkIssuer: 'https://openvibe.network' };++ const keys = createKeyStore({ config, log: { warn() {}, info() {} } });++ const now = Math.floor(Date.now() / 1000);++ const claims = { iss: config.networkIssuer, sub: 'app:x', aud: ['inventory.openvibe.network'], iat: now, exp: now + 300 };++ assert.strictEqual(await keys.pemForToken(signJwt(claims, b.privateKey, { kid: 'new' })), pem(b.publicKey), 'the new key for a token signed with it');++ assert.strictEqual(await keys.pemForToken(signJwt(claims, a.privateKey, { kid: 'old' })), pem(a.publicKey));++ assert.strictEqual(await keys.pemForToken(signJwt(claims, a.privateKey)), pem(a.publicKey), 'no kid: the first key');++ assert.strictEqual(await keys.pemForToken('garbage'), pem(a.publicKey), 'an undecodable header falls back to the first key (the guard refuses the token)');++ await jwks.close();++ });++++ await check('with keys cached, a JWKS outage still verifies (the last good keys)', async () => {++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ const jwks = await jwksServer({ doc: { keys: [jwk(publicKey, 'k1')] } });++ const { createKeyStore } = require('../server/auth/keys');++ const config = { networkInternalUrl: jwks.url, networkUrl: jwks.url, networkIssuer: 'https://openvibe.network' };++ const keys = createKeyStore({ config, log: console });++ await keys.ensure();++ const before = jwks.seen.length;++ jwks.setFail(true); // Network goes away++ const now = Math.floor(Date.now() / 1000);++ const token = signJwt({ iss: config.networkIssuer, sub: 'usr_demo', username: 'demo', aud: ['openvibe.network'], iat: now, exp: now + 300 }, privateKey, { kid: 'k1' });++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: 'openvibe.network' });++ assert.strictEqual(v.ok, true, `outage should not break verification: ${v.reason || ''}`);++ assert.strictEqual(jwks.seen.length, before, 'no new fetch during the outage');++ await jwks.close();++ });++++ await check('readiness (network_jwks) shows the SDK JWKS state', async () => {++ const t = await boot();++ const r = await t.get('/api/ready');++ const b = r.json();++ assert.strictEqual(b.checks.network_jwks.status, 'ok', `readiness JWKS not ok: ${JSON.stringify(b.checks.network_jwks)}`);++ assert.ok(b.checks.network_jwks.detail && b.checks.network_jwks.detail.keys >= 1, `detail.keys should be >= 1: ${JSON.stringify(b.checks.network_jwks.detail)}`);++ assert.strictEqual(b.checks.network_jwks.detail.failures, 0);++ await t.close();++ });++++ await check('an unreachable JWKS never names the URL or the fetch error (503, /api/ready)', async () => {++ // Port 9 (discard) refuses instantly: the SDK's message is "JWKS http://127.0.0.1:9/…: connect ECONNREFUSED".++ const { createKeyStore } = require('../server/auth/keys');++ const config = { networkInternalUrl: 'http://127.0.0.1:9', networkUrl: 'http://127.0.0.1:9', networkIssuer: 'https://openvibe.network' };++ const keys = createKeyStore({ config, log: { warn() {}, log() {}, info() {}, error() {} } });++ await keys.ensure();++ const now = Math.floor(Date.now() / 1000);++ const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ const token = signJwt({ iss: config.networkIssuer, sub: 'usr_demo', aud: ['openvibe.network'], iat: now, exp: now + 300 }, privateKey);++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: 'openvibe.network' });++ assert.strictEqual(v.ok, false);++ assert.strictEqual(v.code, 'token.no_key', `the failure is token.no_key: ${v.code}`);++ assert.strictEqual(v.reason, 'signing key not loaded yet', 'a fixed public reason, not the SDK text');++ assert.ok(!/127\.0\.0\.1|jwks|ECONNREFUSED|fetch failed/i.test(v.reason), `reason leaks: ${v.reason}`);++++ // The same unreachable JWKS behind a booted service: neither sign-in nor /api/ready names it.++ const t = await boot({ env: { OV_NETWORK_INTERNAL_URL: 'http://127.0.0.1:9', OV_NETWORK_URL: 'http://127.0.0.1:9' } });++ const ready = await t.get('/api/ready');++ const readyText = ready.text;++ assert.ok(!/127\.0\.0\.1|\.well-known\/jwks|ECONNREFUSED|fetch failed/i.test(readyText),++ `/api/ready leaks the JWKS URL or the fetch error: ${readyText.slice(0, 400)}`);++ const me = await t.get('/auth/me', { cookie: `inventory_at=${token}` });++ assert.strictEqual(me.status, 401, `an unverifiable session token is 401: ${me.status} ${me.text.slice(0, 200)}`);++ assert.ok(!/127\.0\.0\.1|\.well-known\/jwks|ECONNREFUSED|fetch failed/i.test(me.text),++ `the sign-in answer leaks the JWKS URL or the fetch error: ${me.text.slice(0, 300)}`);++ await t.close();++ });++++ await check('an expired session token starts no session (the SDK takes the clock as a number)', async () => {++ const t = await boot();++ const u = t.network.addUser('expired-demo');++ // Signed by the Network, but exp is an hour in the past: the SDK's Math.floor(now / 1000) must see it.++ const expired = t.network.userToken(u, { ttl: -3600 });++ const me = await t.get('/auth/me', { cookie: `inventory_at=${expired}` });++ assert.strictEqual(me.status, 401, `an expired cookie is not a session: ${me.status} ${me.text.slice(0, 200)}`);++ const page = await t.get('/', { cookie: `inventory_at=${expired}` });++ assert.ok(page.status < 400, `a page still renders: ${page.status}`);++ assert.ok(!page.text.includes('expired-demo'), 'the expired token signed nobody in');++ await t.close();++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });\\ No newline at end of filennew file mode 100644@@@ -0,0 +1,112 @@++'use strict';++/**++ * Sign-in with PKCE S256 (client id inventory, cookies inventory_at/inventory_rt/inventory_oauth), truthful readiness++ * (the database is required; JWKS, the OAuth client and Valkey are optional), /release.json, loopback-only++ * /metrics, and pages useful without JavaScript.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const user = t.network.addUser('kim');++++ await check('login redirects to Network with state and an S256 code challenge', async () => {++ const r = await t.get('/auth/login?next=/updates');++ assert.strictEqual(r.status, 302);++ const loc = new URL(r.headers.get('location'));++ assert.strictEqual(loc.origin + loc.pathname, `${t.network.url}/oauth/authorize`);++ assert.strictEqual(loc.searchParams.get('client_id'), 'inventory');++ assert.strictEqual(loc.searchParams.get('code_challenge_method'), 'S256');++ assert.match(loc.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);++ assert.match(loc.searchParams.get('state'), /^[0-9a-f]{32}$/);++ assert.match(r.headers.get('set-cookie'), /inventory_oauth=.*Path=\/auth.*HttpOnly/i);++ });++++ await check('callback exchanges the code with the matching verifier and sets httpOnly session cookies', async () => {++ const login = await t.get('/auth/login?next=/updates');++ const loc = new URL(login.headers.get('location'));++ const flowCookie = login.headers.get('set-cookie').split(';')[0];++ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));++ const r = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });++ assert.strictEqual(r.status, 302, r.text);++ assert.strictEqual(r.headers.get('location'), '/updates');++ const set = r.headers.get('set-cookie');++ assert.match(set, /inventory_at=[^;]+;.*HttpOnly/i);++ assert.match(set, /inventory_rt=[^;]+;.*HttpOnly/i);++ const at = set.match(/inventory_at=([^;]+)/)[1];++ const me = await t.get('/auth/me', { cookie: `inventory_at=${at}` });++ assert.strictEqual(me.json().user.username, 'kim');++ assert.ok(!me.text.includes(at), '/auth/me never returns the token');++ });++++ await check('a wrong state or a verifier that does not match is refused', async () => {++ const login = await t.get('/auth/login');++ const loc = new URL(login.headers.get('location'));++ const flowCookie = login.headers.get('set-cookie').split(';')[0];++ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));++ const bad = await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: flowCookie });++ assert.strictEqual(bad.status, 400);++ const other = t.network.issueCode(user, crypto.createHash('sha256').update('another-verifier').digest('base64url'));++ const mismatch = await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });++ assert.strictEqual(mismatch.status, 400, 'Network refuses the PKCE check; the service reports it');++ const noCookie = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`);++ assert.strictEqual(noCookie.status, 400);++ });++++ await check('next= only accepts same-site paths', async () => {++ const r = await t.get('/auth/login?next=//evil.example/x');++ const flow = decodeURIComponent(r.headers.get('set-cookie').match(/inventory_oauth=([^;]+)/)[1]);++ assert.strictEqual(JSON.parse(flow).next, '/');++ });++++ await check('readiness is truthful: db required; JWKS, the OAuth client and Valkey optional', async () => {++ const r = await t.get('/api/ready');++ const b = r.json();++ assert.strictEqual(r.status, 200);++ assert.strictEqual(b.checks.db.status, 'ok');++ assert.strictEqual(b.checks.db.required, true);++ assert.strictEqual(b.checks.network_jwks.status, 'ok');++ assert.strictEqual(b.checks.network_jwks.required, false);++ assert.strictEqual(b.checks.oauth_client.status, 'ok');++ assert.strictEqual(b.checks.oauth_client.required, false);++ assert.ok('valkey' in b.checks, 'the optional Valkey check is listed');++ });++++ await check('/release.json and /metrics (loopback only)', async () => {++ const rel = await t.get('/release.json');++ assert.strictEqual(rel.status, 200);++ assert.strictEqual(rel.json().service, 'inventory');++ assert.deepStrictEqual(require('openvibe-contracts').validate('registry.release-manifest@1', rel.json()).errors, []);++ assert.strictEqual(rel.json().metrics_url, '/release-metrics');++ const m = await t.get('/metrics');++ assert.strictEqual(m.status, 200);++ assert.match(m.text, /http_requests_total|release_info/);++ const proxied = await t.get('/metrics', { headers: { 'x-forwarded-for': '203.0.113.9' } });++ assert.notStrictEqual(proxied.status, 200, 'a proxied request is not loopback');++ });++++ await check('public pages are complete without JavaScript (noscript nav, SSR footer)', async () => {++ for (const p of ['/', '/updates']) {++ const r = await t.get(p);++ assert.strictEqual(r.status, 200, p);++ assert.match(r.text, /<noscript><nav/);++ assert.match(r.text, /<footer id="ov-footer"/);++ assert.match(r.text, /<main id="main"/);++ }++ });++++ await check('the API answers /ping and unknown routes are problem+json 404', async () => {++ const ping = await t.get('/api/v1/ping');++ assert.strictEqual(ping.status, 200);++ assert.deepStrictEqual(ping.json(), { ok: true, service: 'inventory' });++ const gone = await t.get('/api/v1/nope');++ assert.strictEqual(gone.status, 404);++ assert.match(gone.headers.get('content-type'), /application\/problem\+json/);++ assert.strictEqual(gone.json().code, 'route.not_found');++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++/**++ * Per-caller rate limits (server/http/caller-limits.js): past its limit one caller gets 429 problem+json++ * `rate_limited` with Retry-After, before the route does any work, while another caller still passes; the++ * window reopens on the clock. A person counts as themselves, anyone else by address. Reads take++ * INVENTORY_LIMITS_MINUTE/INVENTORY_LIMITS_HOUR; the product's expensive routes get their own numbers in BUDGETS.++ * Health, ready, release.json and metrics are never limited; refusals are logged (no token) and counted in++ * inventory_rate_limited_total.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { caller, BUDGETS } = require('../server/http/caller-limits');++++const READ = 'inventory.item.read';++++(async () => {++ // The limiter's clock: 15 s into a minute, so the minute window has 45 s left. Reads: 3 a minute.++ let clock = Date.UTC(2026, 8, 27, 12, 0, 15);++ const t = await boot({ callerLimits: true, limitsNow: () => clock, env: { INVENTORY_LIMITS_MINUTE: '3', INVENTORY_LIMITS_HOUR: '100' } });++ const rosa = t.network.addUser('rosa');++ const sam = t.network.addUser('sam');++++ await check('the product\'s writes have their own numbers: equipping, granting, defining', () => {++ assert.deepStrictEqual(Object.keys(BUDGETS).sort(), ['inventory.define', 'inventory.equip', 'inventory.grant']);++ Object.values(BUDGETS).forEach((b) => assert.ok(b.minute > 0 && b.hour >= b.minute, JSON.stringify(b)));++ });++++ await check('a read: 3 a minute per address, then 429 rate_limited with Retry-After; another address passes', async () => {++ const from = (ip) => ({ headers: { 'x-forwarded-for': ip } });++ for (let i = 0; i < 3; i++) assert.strictEqual((await t.get('/api/v1/ping', from('203.0.113.7'))).status, 200);++ const r = await t.get('/api/v1/ping', from('203.0.113.7'));++ assert.strictEqual(r.status, 429);++ assert.match(r.headers.get('content-type'), /application\/problem\+json/);++ assert.ok(Number(r.headers.get('retry-after')) > 0 && Number(r.headers.get('retry-after')) <= 45, r.headers.get('retry-after'));++ const body = r.json();++ assert.strictEqual(body.code, 'rate_limited');++ assert.ok(body.detail.includes(READ), body.detail);++ assert.strictEqual((await t.get('/api/v1/ping', from('203.0.113.8'))).status, 200, 'another address still passes');++ });++++ await check('a signed-in person counts as themselves, not by address', async () => {++ for (let i = 0; i < 3; i++) assert.strictEqual((await t.get('/api/v1/ping', { as: rosa, headers: { 'x-forwarded-for': '203.0.113.7' } })).status, 200, 'rosa is not the address that was refused');++ const r = await t.get('/api/v1/ping', { as: rosa, headers: { 'x-forwarded-for': '203.0.113.9' } });++ assert.strictEqual(r.status, 429, 'rosa is over her own limit from any address');++ assert.strictEqual((await t.get('/api/v1/ping', { as: sam, headers: { 'x-forwarded-for': '203.0.113.9' } })).status, 200, 'another person still passes');++ });++++ await check('the next minute opens the window again', async () => {++ clock += 60_000;++ assert.strictEqual((await t.get('/api/v1/ping', { as: rosa })).status, 200);++ });++++ await check('a budget that was never declared cannot be taken', () => {++ assert.throws(() => t.ctx.limits.budget('inventory.thing.create'), /no budget named/);++ });++++ await check('health, ready, release.json and metrics are never limited', async () => {++ for (let i = 0; i < 6; i++) {++ assert.strictEqual((await t.get('/api/health')).status, 200);++ assert.notStrictEqual((await t.get('/api/ready')).status, 429);++ assert.strictEqual((await t.get('/release.json')).status, 200);++ assert.strictEqual((await t.get('/metrics')).status, 200);++ }++ });++++ await check('refusals are counted in inventory_rate_limited_total and logged without a token', async () => {++ const m = (await t.get('/metrics')).text;++ const counted = m.split('\n').filter((l) => l.includes('inventory_rate_limited_total')).join('\n');++ assert.ok(/inventory_rate_limited_total\{limit="inventory\.item\.read",window="minute"\} 2/.test(m), counted);++ const logs = t.logs();++ assert.ok(logs.includes(`[Limits] ${READ}: user:${rosa.subject} refused`), 'one log line per refusal');++ assert.ok(logs.includes(`[Limits] ${READ}: ip:203.0.113.7 refused`), 'the address refusal is logged');++ assert.ok(!/\[Limits\][^\n]*eyJ/.test(logs), 'a token in the log');++ });++++ await check('who is counted', () => {++ assert.strictEqual(caller({ principal: { requester: 'app:app_x' }, viewer: { kind: 'user', subject: 'usr_a' }, ip: '203.0.113.1' }), 'app:app_x', 'a principal wins');++ assert.strictEqual(caller({ viewer: { kind: 'user', subject: 'usr_a' }, ip: '203.0.113.1' }), 'user:usr_a');++ assert.strictEqual(caller({ viewer: { kind: 'anonymous' }, ip: '198.51.100.4' }), 'ip:198.51.100.4');++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++/**++ * The public crawl artifacts (plan T11), fetched from the booted app: /robots.txt, /sitemap.xml,++ * /llms.txt, /llms-full.txt and the home page's JSON-LD. Each is served with the right status and++ * content type, carries at least one real entry, lists public pages only, and takes lastmod from the++ * site's own data (STATUS.json) — never from the clock.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./helpers/boot');++++const STATUS_UPDATED = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'STATUS.json'), 'utf8')).updated;++// llms.txt names the public machine endpoints on purpose, so only sign-in and the API are private there.++const PRIVATE = ['/auth/', '/api/'];++++(async () => {++ const t = await boot();++ try {++ await check('robots.txt: 200 text/plain, sign-in and the API disallowed, the public pages crawlable, sitemap named', async () => {++ const r = await t.get('/robots.txt');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /^text\/plain/);++ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);++ for (const d of ['/auth/', '/api/']) assert.ok(r.text.includes(`Disallow: ${d}`), `missing Disallow: ${d}`);++ for (const d of ['/updates']) assert.ok(!r.text.includes(`Disallow: ${d}`), `${d} must stay crawlable`);++ assert.ok(r.text.includes('Sitemap: https://inventory.openvibe.network/sitemap.xml'), 'sitemap not named');++ assert.ok(/^User-agent: \*$/m.test(r.text), 'no User-agent: * group');++ });++++ await check('sitemap.xml: 200 application/xml, real entries, lastmod from the data', async () => {++ const r = await t.get('/sitemap.xml');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /^application\/xml/);++ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);++ for (const p of ['/', '/updates']) assert.ok(r.text.includes(`<loc>https://inventory.openvibe.network${p}</loc>`), `no ${p} entry`);++ for (const p of ['/auth/login', '/api/v1/ping']) assert.ok(!r.text.includes(`<loc>https://inventory.openvibe.network${p}</loc>`), `${p} is not a public page`);++ const lastmods = [...r.text.matchAll(/<lastmod>([^<]+)<\/lastmod>/g)].map((m) => m[1]);++ assert.ok(lastmods.length > 0, 'no lastmod anywhere');++ assert.ok(lastmods.every((d) => /^\d{4}-\d{2}-\d{2}$/.test(d)), `bad lastmod: ${lastmods.slice(0, 3)}`);++ assert.ok(lastmods.every((d) => d === STATUS_UPDATED), `lastmod must come from STATUS.json (${STATUS_UPDATED}), got ${[...new Set(lastmods)].join(', ')}`);++ for (const p of PRIVATE) assert.ok(!r.text.includes(`https://inventory.openvibe.network${p}`), `private path in sitemap: ${p}`);++ });++++ await check('llms.txt: 200 text/plain, real links, nothing private', async () => {++ const r = await t.get('/llms.txt');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /^text\/plain/);++ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);++ assert.ok(r.text.startsWith('# OpenVibe.Inventory'), 'no title');++ assert.ok(r.text.includes('(https://inventory.openvibe.network/updates)'), 'no updates link');++ assert.ok(r.text.includes('(https://openvibe.services)'), 'no pointer to the developer platform');++ assert.ok(r.text.includes('https://inventory.openvibe.network/sitemap.xml'), 'sitemap not listed');++ assert.ok(r.text.includes('(https://inventory.openvibe.network/llms-full.txt)'), 'llms-full.txt not listed');++ for (const p of ['/auth/']) assert.ok(!r.text.includes(`https://inventory.openvibe.network${p}`), `private path in llms.txt: ${p}`);++ });++++ await check('llms-full.txt: 200 text/plain, the site title, and every page the sitemap lists', async () => {++ const r = await t.get('/llms-full.txt');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /^text\/plain/);++ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);++ assert.ok(r.text.startsWith('# OpenVibe.Inventory'), 'no title');++ const sitemap = await t.get('/sitemap.xml');++ const locs = [...sitemap.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);++ assert.ok(locs.length >= 2, `sitemap too small: ${locs.length}`);++ for (const loc of locs) assert.ok(r.text.includes(loc), `llms-full.txt is missing ${loc}`);++ assert.ok(r.text.includes('URL: https://inventory.openvibe.network/updates\n'), 'no /updates entry');++ for (const p of PRIVATE) assert.ok(!r.text.includes(`https://inventory.openvibe.network${p}`), `private path in llms-full.txt: ${p}`);++ });++++ await check('home page: WebSite + the site\'s primary type, the same for every crawler', async () => {++ const r = await t.get('/');++ assert.strictEqual(r.status, 200);++ const blocks = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++ const types = blocks.map((b) => b['@type']);++ assert.ok(types.includes('WebSite'), `no WebSite node: ${types}`);++ assert.ok(types.includes('WebApplication'), `no primary-type node: ${types}`);++ const site = blocks.find((b) => b['@type'] === 'WebSite');++ assert.strictEqual(site.url, 'https://inventory.openvibe.network');++ assert.strictEqual(site.name, 'OpenVibe.Inventory');++ const page = blocks.find((b) => b['@type'] === 'WebPage');++ assert.strictEqual(page.url, 'https://inventory.openvibe.network/');++ });++ } finally { await t.close(); }++ done();++})();nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++// Many requests per test file: the per-address limits would answer 429 part-way. The per-caller limits++// (server/http/caller-limits.js) count nobody unless a test asks: boot({ callerLimits: true, limitsNow }).++process.env.INVENTORY_RATE_LIMIT_PER_MIN = process.env.INVENTORY_RATE_LIMIT_PER_MIN || '100000';++process.env.INVENTORY_API_RATE_LIMIT_PER_MIN = process.env.INVENTORY_API_RATE_LIMIT_PER_MIN || '100000';++/**++ * Boots OpenVibe.Inventory on a temp database with a mock Network (test/helpers/mocks.js), captures every log line, and++ * returns a small HTTP client. Every test file gets its own.++ *++ * const t = await boot();++ * t.get(path, { as: user, bearer, json, form, headers, method, cookie })++ * t.signIn(user) → cookie header value (inventory_at = a Network user token, as after /auth/callback)++ * t.logs(), t.dbDump()++ */++const http = require('http');++const { startNetwork } = require('./mocks');++++const captured = [];++for (const m of ['log', 'info', 'warn', 'error']) {++ const orig = console[m].bind(console);++ console[m] = (...a) => { captured.push(a.map(String).join(' ')); if (process.env.VERBOSE) orig(...a); };++}++++async function boot(opts = {}) {++ const network = await startNetwork(opts.network || {});++ const env = {++ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://inventory.openvibe.network', TRUST_PROXY: '1',++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,++ OV_OAUTH_CLIENT_ID: 'inventory', OV_OAUTH_CLIENT_SECRET: 'inventory-secret', COOKIE_SECURE: 'false',++ ...(opts.env || {}),++ };++ for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];++ const configLib = require('../../server/config');++ const { createApp } = require('../../server/app');++ const log = { log: (...a) => captured.push(a.map(String).join(' ')), warn: (...a) => captured.push(a.map(String).join(' ')), error: (...a) => captured.push(a.map(String).join(' ')), info: (...a) => captured.push(a.map(String).join(' ')) };++++ const config = configLib.load(env);++ const { createStore } = require('../../server/db');++ const testdb = await require('./db').testDb();++ const store = createStore(testdb.db, { now: opts.now });++ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend });++ await built.ctx.keys.ensure();++ const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++++ const signIn = (user) => `inventory_at=${network.userToken(user)}`;++++ async function get(p, o = {}) {++ const headers = { ...(o.headers || {}) };++ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');++ if (o.cookie) headers.cookie = o.cookie;++ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;++ let body = o.body;++ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }++ if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }++ const res = await fetch(base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });++ const buf = Buffer.from(await res.arrayBuffer());++ const text = buf.toString('utf8');++ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };++ }++++ async function dbDump() {++ const db = built.ctx.s.db;++ const tables = (await db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()")).map((r) => r.name);++ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.many(`SELECT * FROM "${tname}"`))))).join('\n');++ }++++ const t = {++ base, network, config, ctx: built.ctx, get, signIn, dbDump,++ logs: () => captured.join('\n'),++ async close() {++ await new Promise((r) => server.close(r));++ built.ctx.keys.client.stop();++ await testdb.close();++ await network.close();++ },++ };++ return t;++}++++let failures = 0;++async function check(name, fn) {++ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }++}++function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }++++module.exports = { boot, check, done };nnew file mode 100644@@@ -0,0 +1,11 @@++'use strict';++/**++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with++ * INVENTORY_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.++ */++const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');++const { MIGRATIONS } = require('../../server/db');++++const testDb = ({ store = process.env.INVENTORY_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'inventory', max });++++module.exports = { testDb, pgAvailable };nnew file mode 100644@@@ -0,0 +1,334 @@++'use strict';++/**++ * An in-process stand-in for OpenVibe.Network (sign-in, JWKS, tokens), with a real RS256 key pair. Much of it served++ * the developer console that moved to OpenVibe.Services; what a service's tests use is sign-in and the JWKS.++ *++ * Network JWKS; /oauth/token (authorization_code + PKCE S256 for client `inventory`, refresh_token,++ * client_credentials for `inventory` and for developer apps with Network's app rules:++ * hashed secrets, sandbox audiences, cap = approved grants ∩ allowance for the audience);++ * /oauth/revoke; /.well-known/openvibe; /api/v1/registry/services; and /api/v1/projects…++ * with the same shapes, roles and problem codes as OpenVibe.Network's developer API++ * (simplified: no audit paging, no rate limits). setDown(true) makes it refuse++ * connections-equivalent (503) and drop() closes the socket.++ * GET /api/v1/projects/:project/usage (owner/admin or staff; days 1-90, env all|sandbox|production)++ * answers what setUsage() stored for the project, or an empty network.project-usage-result@1.++ * POST /api/v1/projects/:project/export-tokens as Network mints them (owner/admin only,++ * sub app:app_<project ULID>, read-only caps, purpose export), recorded in exportTokens.++ * Every request is recorded in `.requests` so tests can assert what was (not) called.++ */++const http = require('http');++const crypto = require('crypto');++const { serviceAuth, ids, capabilities } = require('openvibe-contracts');++++function listen(handler) {++ return new Promise((resolve) => {++ const sockets = new Set();++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks);++ const json = (status, obj, headers = {}) => { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); };++ Promise.resolve(handler(req, raw, json, res)).catch((err) => { json(500, { error: String(err && err.message) }); });++ });++ });++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });++ server.listen(0, '127.0.0.1', () => resolve({++ server, url: `http://127.0.0.1:${server.address().port}`,++ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),++ }));++ });++}++++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');++function signJwt(claims, privateKey) {++ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}`;++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;++}++const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');++const RANK = { viewer: 1, developer: 2, admin: 3, owner: 4 };++++async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.media'] } = {}) {++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });++ const st = {++ users: new Map(), byUsername: new Map(), projects: new Map(), members: new Map(), apps: new Map(), creds: new Map(),++ grants: new Map(), quotas: new Map(), usage: new Map(), audit: [], codes: new Map(), refresh: new Map(),++ catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,++ };++ let issuer = null;++ let nextUserId = 1;++++ const problem = (json, status, code, detail) => json(status, { type: `https://openvibe.network/problems/${code}`, title: 'Error', status, code, detail, error: detail, request_id: `req_${crypto.randomBytes(6).toString('hex')}` }, { 'Content-Type': 'application/problem+json' });++++ function addUser(username, { role = 'user' } = {}) {++ const u = { id: nextUserId++, subject: ids.newId('user'), username, display_name: username, role };++ st.users.set(u.id, u); st.byUsername.set(username, u);++ return u;++ }++ function userToken(u, { ttl = 3600, iat = null } = {}) {++ const now = iat != null ? iat : Math.floor(Date.now() / 1000);++ return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);++ }++ function userFrom(req) {++ const h = String(req.headers.authorization || '');++ if (!h.startsWith('Bearer ')) return null;++ const parts = h.slice(7).split('.');++ if (parts.length !== 3) return null;++ const ok = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicPem, Buffer.from(parts[2], 'base64url'));++ if (!ok) return null;++ const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));++ if (c.exp * 1000 < Date.now()) return 'expired';++ return st.users.get(c.sub) || null;++ }++++ // ── Projects model ──++ const roleOf = (p, u) => (st.members.get(p.id) || new Map()).get(u.subject) || null;++ const projectView = (p, role) => ({++ id: p.id, name: p.name, owner: { type: 'user', id: p.owner }, role: role || null, environment_policy: p.environment_policy,++ environments: p.environment_policy === 'sandbox' ? ['sandbox'] : ['sandbox', 'production'], allowance: [...p.allowance],++ created_at: p.created_at, archived_at: p.archived_at, counts: { members: (st.members.get(p.id) || new Map()).size, apps: [...st.apps.values()].filter((a) => a.project_id === p.id && !a.revoked_at).length },++ });++ const appView = (a) => ({++ id: a.id, subject: { type: 'app', id: a.id }, project_id: a.project_id, name: a.name, environment: a.environment, client_id: a.id,++ client_type: a.client_type, redirect_uris: a.redirect_uris, created_at: a.created_at, revoked_at: a.revoked_at,++ grants: [...st.grants.values()].filter((g) => g.app_id === a.id && g.status === 'approved').map((g) => g.capability).sort(),++ });++ function newCredential(app) {++ const secret = `ovsec_${crypto.randomBytes(32).toString('base64url')}`;++ const c = { id: ids.newId('project').replace(/^prj_/, 'crd_'), app_id: app.id, hash: sha256(secret), hint: secret.slice(-4), created_at: new Date().toISOString(), expires_at: null, revoked_at: null };++ st.creds.set(c.id, c);++ return { c, secret };++ }++ const credState = (c) => (c.revoked_at ? 'revoked' : (c.expires_at ? (Date.parse(c.expires_at) < Date.now() ? 'expired' : 'expiring') : 'active'));++ const grantable = (id) => { const c = capabilities.get(id); return c && c.status === 'active' && ['public', 'partner'].includes(c.visibility); };++ const defaultCatalog = () => capabilities.manifests.filter((c) => grantable(c.id)).map((c) => ({ id: c.id, owner: c.owner, audience: `openvibe.${c.owner}`, visibility: c.visibility, description: c.description || '', resourceConstraints: c.resourceConstraints, quotaClass: c.quotaClass }));++++ async function projectsApi(req, raw, json, u, parts, q) {++ let body = {};++ if (raw.length) { try { body = JSON.parse(raw.toString('utf8')); } catch { return problem(json, 400, 'request.malformed_json', 'body is not valid JSON'); } }++ const m = req.method;++ if (parts.length === 1 && parts[0] === 'catalog' && m === 'GET') return json(200, { capabilities: st.catalog || defaultCatalog(), rule: 'only active public capabilities' });++ if (!parts.length && m === 'GET') {++ const list = [...st.projects.values()].filter((p) => roleOf(p, u) || (q.get('all') === '1' && u.role === 'admin')).map((p) => projectView(p, roleOf(p, u)));++ return json(200, { projects: list });++ }++ if (!parts.length && m === 'POST') {++ const name = String(body.name || '').trim();++ if (!name || name.length > 80) return problem(json, 422, 'project.invalid', 'name must be 1-80 characters');++ const p = { id: ids.newId('project'), name, owner: u.subject, environment_policy: 'sandbox', allowance: [], created_at: new Date().toISOString(), archived_at: null };++ st.projects.set(p.id, p); st.members.set(p.id, new Map([[u.subject, 'owner']]));++ return json(201, projectView(p, 'owner'));++ }++ const p = st.projects.get(parts[0]);++ const role = p ? roleOf(p, u) : null;++ const staff = u.role === 'admin';++ if (!p || (!role && !staff)) return problem(json, 404, 'project.not_found', 'no such project');++ const need = (r) => { if (!role || RANK[role] < RANK[r]) { problem(json, 403, 'project.forbidden', `requires ${r} role`); return false; } return true; };++ const rest = parts.slice(1);++ if (!rest.length && m === 'GET') return json(200, projectView(p, role));++ if (rest[0] === 'archive' && m === 'POST') { if (role !== 'owner' && !staff) return problem(json, 403, 'project.forbidden', 'owner only'); p.archived_at = new Date().toISOString(); for (const a of st.apps.values()) if (a.project_id === p.id) a.revoked_at = a.revoked_at || p.archived_at; return json(200, projectView(p, role)); }++ if (rest[0] === 'members') {++ const mem = st.members.get(p.id);++ if (rest.length === 1 && m === 'GET') return json(200, { members: [...mem.entries()].map(([s, r]) => { const x = [...st.users.values()].find((y) => y.subject === s); return { subject: { type: 'user', id: s }, username: x && x.username, display_name: x && x.display_name, role: r, added_at: p.created_at }; }) });++ if (rest.length === 1 && m === 'POST') {++ if (!need('admin')) return;++ const target = st.byUsername.get(String(body.username || ''));++ if (!target) return problem(json, 404, 'member.user_not_found', 'no such account');++ if (!['admin', 'developer', 'viewer'].includes(body.role)) return problem(json, 422, 'member.invalid', 'role is one of admin, developer, viewer');++ if (body.role === 'admin' && role !== 'owner') return problem(json, 403, 'member.forbidden', `a ${role} cannot assign admin`);++ if (mem.has(target.subject)) return problem(json, 409, 'member.exists', 'already a member');++ mem.set(target.subject, body.role);++ return json(201, { subject: { type: 'user', id: target.subject }, role: body.role });++ }++ const s = rest[1];++ if (!mem.has(s)) return problem(json, 404, 'member.not_found', 'not a member');++ if (m === 'PATCH') { if (!need('admin')) return; mem.set(s, body.role); return json(200, { role: body.role }); }++ if (m === 'DELETE') { if (s !== u.subject && !need('admin')) return; if (mem.get(s) === 'owner') return problem(json, 403, 'member.forbidden', 'the owner cannot be removed'); mem.delete(s); res204(json); return; }++ }++ if (rest[0] === 'quotas' && m === 'GET') return json(200, { quotas: [...st.quotas.values()].filter((x) => x.project_id === p.id).map((x) => ({ capability: x.capability, limit: x.limit, window: x.window, unit: x.unit, enforced_by: `openvibe.${capabilities.get(x.capability).owner}`, updated_at: x.updated_at })), note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' });++ if (rest[0] === 'usage' && m === 'GET') {++ if ((!role || RANK[role] < RANK.admin) && !staff) return problem(json, 403, 'project.forbidden', 'requires admin role');++ const days = q.get('days') || '30';++ const env = q.get('env') || 'all';++ if (!/^\d{1,3}$/.test(days) || Number(days) < 1 || Number(days) > 90) return problem(json, 422, 'usage.invalid', 'days is a whole number from 1 to 90');++ if (!['all', 'sandbox', 'production'].includes(env)) return problem(json, 422, 'usage.invalid', 'env is all, sandbox or production');++ const today = new Date().toISOString().slice(0, 10);++ const range = { days: Number(days), from: new Date(Date.now() - (Number(days) - 1) * 86400000).toISOString().slice(0, 10), to: today };++ const u = st.usage.get(p.id);++ if (u) return json(200, { ...u, project_id: p.id, env, range });++ return json(200, { project_id: p.id, env, range, generated_at: new Date().toISOString(), last_recorded_at: null, freshness: 'No usage has been recorded for this project yet.', totals: [], daily: [], quotas: [], errors: { total: 0, by_code: [], recent: [] } });++ }++ if (rest[0] === 'audit' && m === 'GET') { if (!role || RANK[role] < RANK.admin) { if (!staff) return problem(json, 403, 'project.forbidden', 'requires admin role'); } return json(200, { entries: st.audit.filter((e) => e.project_id === p.id).slice().reverse(), next_before: null }); }++ if (rest[0] === 'apps') {++ if (rest.length === 1 && m === 'GET') return json(200, { apps: [...st.apps.values()].filter((a) => a.project_id === p.id).map(appView) });++ if (rest.length === 1 && m === 'POST') {++ const env = String(body.environment || 'sandbox');++ if (!['sandbox', 'production'].includes(env)) return problem(json, 422, 'app.invalid', 'environment is sandbox or production');++ if (!need(env === 'production' ? 'admin' : 'developer')) return;++ if (env === 'production' && p.environment_policy === 'sandbox') return problem(json, 403, 'app.environment_not_allowed', 'this project may only have sandbox apps (staff enable production)');++ const type = String(body.type || 'confidential');++ const redirects = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];++ for (const x of redirects) { try { const url = new URL(x); if (url.protocol !== 'https:' && url.hostname !== 'localhost') return problem(json, 422, 'app.invalid', 'redirect URIs must be https (http only for loopback)'); } catch { return problem(json, 422, 'app.invalid', `bad redirect URI ${x}`); } }++ if (type === 'public' && !redirects.length) return problem(json, 422, 'app.invalid', 'a public app needs at least one redirect URI');++ const a = { id: ids.newId('app'), project_id: p.id, name: String(body.name || '').trim() || 'app', environment: env, client_type: type, redirect_uris: redirects, created_at: new Date().toISOString(), revoked_at: null };++ st.apps.set(a.id, a);++ const out = appView(a);++ if (type === 'confidential') { const { c, secret } = newCredential(a); out.credential = { id: c.id, client_secret: secret, hint: c.hint, shown_once: true }; }++ st.audit.push({ id: st.audit.length + 1, project_id: p.id, at: new Date().toISOString(), actor: `user:${u.subject}`, action: 'app.created', target: `app:${a.id}`, detail: { name: a.name }, event_type: 'network.app.created' });++ return json(201, out);++ }++ const a = st.apps.get(rest[1]);++ if (!a || a.project_id !== p.id) return problem(json, 404, 'app.not_found', 'no such app');++ const manage = a.environment === 'production' ? 'admin' : 'developer';++ if (rest.length === 2 && m === 'GET') return json(200, appView(a));++ if (rest.length === 2 && m === 'PATCH') { if (!need(manage)) return; if (Array.isArray(body.redirect_uris)) a.redirect_uris = body.redirect_uris; return json(200, appView(a)); }++ if (rest.length === 2 && m === 'DELETE') { if (!staff && !need(manage)) return; a.revoked_at = new Date().toISOString(); return json(200, appView(a)); }++ if (rest[2] === 'credentials') {++ if (rest.length === 3 && m === 'GET') return json(200, { credentials: [...st.creds.values()].filter((c) => c.app_id === a.id).map((c) => ({ id: c.id, hint: c.hint, state: credState(c), created_at: c.created_at, expires_at: c.expires_at, revoked_at: c.revoked_at, last_used_at: null })) });++ if (rest[3] === 'rotate' && m === 'POST') {++ if (!need(manage)) return;++ if (a.revoked_at) return problem(json, 409, 'app.revoked', 'app is revoked');++ if (a.client_type !== 'confidential') return problem(json, 409, 'credential.public_client', 'public apps have no client secret');++ const overlap = body.overlap_seconds === undefined ? 86400 : body.overlap_seconds;++ const until = new Date(Date.now() + overlap * 1000).toISOString();++ const prev = [];++ for (const c of st.creds.values()) if (c.app_id === a.id && !c.revoked_at) { c.expires_at = until; prev.push({ id: c.id, expires_at: until }); }++ const { c, secret } = newCredential(a);++ return json(201, { credential: { id: c.id, client_secret: secret, hint: c.hint, shown_once: true }, previous: prev });++ }++ if (rest[4] === 'revoke' && m === 'POST') { if (!need(manage)) return; const c = st.creds.get(rest[3]); if (!c || c.app_id !== a.id) return problem(json, 404, 'credential.not_found', 'no such credential'); c.revoked_at = new Date().toISOString(); return json(200, { id: c.id, state: 'revoked' }); }++ }++ if (rest[2] === 'grants') {++ const key = (cap) => `${a.id} ${cap}`;++ const gv = (g) => ({ app_id: g.app_id, capability: g.capability, audience: g.audience, status: g.status, requested_by: g.requested_by, requested_at: g.requested_at, decided_by: g.decided_by || null, decided_at: g.decided_at || null });++ if (rest.length === 3 && m === 'GET') return json(200, { grants: [...st.grants.values()].filter((g) => g.app_id === a.id).map(gv) });++ if (rest.length === 3 && m === 'POST') {++ if (!need('developer')) return;++ const cap = String(body.capability || '');++ const c = capabilities.get(cap);++ if (!c) return problem(json, 422, 'grant.unknown_capability', `no capability ${cap} in the catalog`);++ if (!grantable(cap)) return problem(json, 403, 'grant.not_grantable', `${cap} is ${c.visibility}; only public capabilities are granted to apps`);++ const auto = RANK[role] >= RANK.admin && p.allowance.includes(cap);++ const g = { app_id: a.id, capability: cap, audience: `openvibe.${c.owner}`, status: auto ? 'approved' : 'requested', requested_by: `user:${u.subject}`, requested_at: new Date().toISOString(), decided_at: auto ? new Date().toISOString() : null };++ st.grants.set(key(cap), g);++ return json(201, gv(g));++ }++ const g = st.grants.get(key(rest[3]));++ if (!g) return problem(json, 404, 'grant.not_found', 'no such grant');++ if (m === 'DELETE') { if (!staff && !need('admin')) return; g.status = 'revoked'; return json(200, gv(g)); }++ if (rest[4] === 'approve') { if (!need('admin')) return; if (!p.allowance.includes(g.capability)) return problem(json, 403, 'grant.beyond_allowance', `${g.capability} is not in this project's allowance (staff set it)`); g.status = 'approved'; g.decided_at = new Date().toISOString(); return json(200, gv(g)); }++ if (rest[4] === 'deny') { if (!need('admin')) return; g.status = 'denied'; return json(200, gv(g)); }++ }++ }++ if (rest[0] === 'export-tokens' && rest.length === 1 && m === 'POST') {++ if (!role || RANK[role] < RANK.admin) return problem(json, 403, 'project.forbidden', 'requires admin role');++ const cap = { 'openvibe.media': ['media.object.list', 'media.object.read'], 'openvibe.events': ['events.app.read'] }[body.audience];++ if (!cap) return problem(json, 422, 'export.invalid_audience', 'audience is one of openvibe.media, openvibe.events');++ if (!['sandbox', 'production'].includes(body.env)) return problem(json, 422, 'export.invalid_env', 'env is one of sandbox, production');++ const now = Math.floor(Date.now() / 1000);++ const claims = {++ iss: issuer, sub: `app:app_${p.id.replace(/^prj_/, '')}`, actor_type: 'app', aud: [body.audience], cap, ns: [p.id, `app.${p.id}.*`],++ project_id: p.id, env: body.env, on_behalf_of: u.subject, purpose: 'export', iat: now, exp: now + st.exportTtl, jti: `tok_${crypto.randomBytes(8).toString('hex')}`,++ };++ const token = serviceAuth.signServiceToken(claims, privatePem);++ st.exportTokens.push({ project_id: p.id, audience: body.audience, env: body.env, subject: u.subject, jti: claims.jti, token });++ st.audit.push({ id: st.audit.length + 1, project_id: p.id, at: new Date().toISOString(), actor: `user:${u.subject}`, action: 'project.export_token_issued', target: claims.sub, detail: { audience: body.audience, env: body.env, cap, jti: claims.jti } });++ return json(201, { access_token: token, token_type: 'Bearer', expires_in: st.exportTtl, expires_at: new Date(claims.exp * 1000).toISOString(), scope: cap.join(' '), audience: body.audience, env: body.env, purpose: 'export', subject: claims.sub, project_id: p.id, jti: claims.jti });++ }++ return problem(json, 404, 'route.not_found', 'no such route');++ }++ const res204 = (json) => json(204, {});++++ function mintApp(a, audience, cap) {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, privatePem);++ }++++ const srv = await listen(async (req, raw, json, res) => {++ const url = new URL(req.url, 'http://x');++ st.requests.push({ method: req.method, path: url.pathname, query: url.search, headers: req.headers });++ if (st.down) return problem(json, 503, 'network.down', 'Network is restarting');++ if (url.pathname === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });++ if (url.pathname === '/.well-known/openvibe') return json(200, { name: 'OpenVibe', issuer, contracts: { version: require('openvibe-contracts/package.json').version }, services: [] });++ if (url.pathname === '/api/v1/registry/services') {++ return json(200, { services: [{ id: 'network', name: 'OpenVibe.Network', status: 'stable', domains: ['openvibe.network'], capabilities: [], runtime: { status: 'up', basis: 'ready', checked_at: new Date().toISOString() } }] });++ }++ if (url.pathname === '/oauth/revoke') return json(200, {});++ if (url.pathname === '/oauth/token' && req.method === 'POST') {++ const ct = String(req.headers['content-type'] || '');++ const body = ct.includes('json') ? JSON.parse(raw.toString('utf8') || '{}') : Object.fromEntries(new URLSearchParams(raw.toString('utf8')));++ st.tokenRequests.push({ grant_type: body.grant_type, client_id: body.client_id, audience: body.audience, scope: body.scope });++ if (body.client_id === 'inventory') {++ if (body.client_secret !== 'inventory-secret') return json(401, { error: 'invalid_client', error_description: 'bad client secret' });++ if (body.grant_type === 'authorization_code') {++ const c = st.codes.get(body.code);++ if (!c) return json(400, { error: 'invalid_grant', error_description: 'unknown code' });++ st.codes.delete(body.code);++ const challenge = crypto.createHash('sha256').update(String(body.code_verifier || '')).digest('base64url');++ if (challenge !== c.challenge) return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });++ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;++ st.refresh.set(rt, c.user);++ return json(200, { access_token: userToken(c.user), refresh_token: rt, token_type: 'Bearer' });++ }++ if (body.grant_type === 'refresh_token') {++ const user = st.refresh.get(body.refresh_token);++ if (!user) return json(400, { error: 'invalid_grant', error_description: 'refresh token rejected' });++ st.refresh.delete(body.refresh_token);++ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;++ st.refresh.set(rt, user);++ return json(200, { access_token: userToken(user), refresh_token: rt, token_type: 'Bearer' });++ }++ if (body.grant_type === 'client_credentials') {++ const now = Math.floor(Date.now() / 1000);++ return json(200, { access_token: serviceAuth.signServiceToken({ iss: issuer, sub: 'svc:inventory', actor_type: 'service', aud: [body.audience || 'openvibe.events'], cap: String(body.scope || '').split(/\s+/).filter(Boolean), iat: now, exp: now + 300, jti: crypto.randomUUID() }, privatePem), token_type: 'Bearer', expires_in: 300 });++ }++ }++ const a = st.apps.get(body.client_id);++ if (!a || a.revoked_at) return json(401, { error: 'invalid_client', error_description: 'unknown or revoked app' });++ const cred = [...st.creds.values()].find((c) => c.app_id === a.id && c.hash === sha256(body.client_secret) && credState(c) !== 'revoked' && credState(c) !== 'expired');++ if (!cred) return json(401, { error: 'invalid_client', error_description: 'bad client secret' });++ if (a.environment === 'sandbox' && !sandboxAudiences.includes(body.audience)) return json(400, { error: 'invalid_target', error_description: `${body.audience} does not accept sandbox tokens` });++ const p = st.projects.get(a.project_id);++ const held = [...st.grants.values()].filter((g) => g.app_id === a.id && g.status === 'approved' && g.audience === body.audience && p.allowance.includes(g.capability)).map((g) => g.capability);++ const wanted = body.scope ? String(body.scope).split(/\s+/).filter(Boolean) : held;++ const missing = wanted.filter((w) => !held.includes(w));++ if (missing.length) return json(400, { error: 'invalid_scope', error_description: `not granted: ${missing.join(' ')}` });++ if (!wanted.length) return json(400, { error: 'invalid_scope', error_description: `no grants for audience ${body.audience}` });++ return json(200, { access_token: mintApp(a, body.audience, wanted), token_type: 'Bearer', expires_in: 300, scope: wanted.join(' ') });++ }++ if (url.pathname.startsWith('/api/v1/projects')) {++ const u = userFrom(req);++ if (u === 'expired') return problem(json, 401, 'auth.expired', 'access token expired; refresh it');++ if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');++ if (req.headers['x-internal-key']) return problem(json, 400, 'test.internal_key_seen', 'X-Internal-Key must never be sent');++ const parts = url.pathname.replace(/^\/api\/v1\/projects\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);++ return await projectsApi(req, raw, json, u, parts, url.searchParams);++ }++ return json(404, { error: 'not found' });++ });++ issuer = srv.url;++++ return {++ url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,++ addUser, userToken,++ /** A first-party service's token for this service's audience, holding `cap` (e.g. live with inventory.item.grant). */++ serviceToken(slug, cap, { audience = 'inventory.openvibe.network' } = {}) {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({ iss: issuer, sub: `svc:${slug}`, actor_type: 'service', aud: [audience], cap, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, privatePem);++ },++ /** An authorization code the mock will exchange for this user if the verifier matches. */++ issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },++ setAllowance(projectId, caps) { st.projects.get(projectId).allowance = caps; },++ setEnvironmentPolicy(projectId, policy) { st.projects.get(projectId).environment_policy = policy; },++ setUsage(projectId, body) { st.usage.set(projectId, body); },++ setQuota(projectId, capability, limit, window, unit) { st.quotas.set(`${projectId} ${capability}`, { project_id: projectId, capability, limit, window, unit, updated_at: new Date().toISOString() }); },++ setCatalog(list) { st.catalog = list; },++ setDown(v) { st.down = v; },++ mintApp: (appId, audience, cap, extra = {}) => { const a = st.apps.get(appId); const now = Math.floor(Date.now() / 1000); return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...extra }, privatePem); },++ close: srv.close,++ };++}++++module.exports = { startNetwork, signJwt };nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++/**++ * ADR-054 §8: Live's cosmetics move here row for row. A stand-in Live database (Live's own three columns) holds two++ * people's unlocks and equipped slots, an account without a Network subject and an item Live no longer sells. The dry++ * run changes nothing; the import grants with origin `migrated` and the unlock time, equips the slots, and a second run++ * grants nothing again; verify then reports no difference.++ */++const assert = require('assert');++const { createDb } = require('openvibe-sdk/db');++const { boot, check, done } = require('./helpers/boot');++const { importLive, verifyLive, isoOf } = require('../server/inventory/import-live');++++(async () => {++ const t = await boot();++ const live = createDb({ pglite: true });++ const db = t.ctx.s.db;++ const inv = t.ctx.inv;++ const A = 'usr_01JZ0000000000000000000AAA';++ const B = 'usr_01JZ0000000000000000000BBB';++ const quiet = { log() {}, warn() {} };++++ try {++ await live.exec(`CREATE TABLE users (id bigint PRIMARY KEY, subject_id text);++ CREATE TABLE user_cosmetics (id bigint GENERATED ALWAYS AS IDENTITY, user_id bigint, item_id text, category text, unlocked_at text);++ CREATE TABLE user_equipped (user_id bigint, slot text, item_id text);++ INSERT INTO users VALUES (1, '${A}'), (2, '${B}'), (3, NULL);++ INSERT INTO user_cosmetics (user_id, item_id, category, unlocked_at) VALUES++ (1, 'fx_rainbow', 'name_effect', '2026-09-01 12:00:00'), (1, 'hat_crown', 'hat', '2026-09-02 08:30:00'), (1, 'gary', 'voice', '2026-09-03 00:00:00'),++ (2, 'fx_fire', 'name_effect', '2026-09-04 10:00:00'), (2, 'fx_retired_thing', 'name_effect', '2026-09-05 10:00:00'),++ (3, 'fx_ice', 'name_effect', '2026-09-06 10:00:00');++ INSERT INTO user_equipped VALUES (1, 'name_effect', 'fx_rainbow'), (1, 'hat', 'hat_crown'), (2, 'name_effect', 'fx_fire'), (2, 'particle', 'px_hearts');`);++++ await check('the dry run counts and changes nothing', async () => {++ const r = await importLive({ live, inv, db, apply: false, log: quiet });++ assert.deepStrictEqual([r.people, r.unlocks, r.noSubject, r.granted, r.equipped], [2, 6, 1, 4, 4]);++ assert.deepStrictEqual(r.unknown.sort(), ['fx_retired_thing'], 'an item Live no longer sells is named, not guessed');++ assert.strictEqual(Number(await db.value('SELECT count(*) FROM inventory_instances')), 0);++ });++++ await check('the import grants with origin migrated and the unlock time, and equips the slots', async () => {++ const r = await importLive({ live, inv, db, apply: true, log: quiet });++ assert.deepStrictEqual([r.granted, r.alreadyHere, r.equipped, r.notOwned], [4, 0, 3, 1], 'bob wears hearts he never unlocked: left empty');++ const mine = (await inv.inventory(A, { own: true })).instances;++ assert.strictEqual(mine.length, 3);++ assert.ok(mine.every((i) => i.origin === 'migrated'));++ const crown = await inv.byAlias('service:live', 'hat_crown');++ assert.strictEqual(mine.find((i) => i.definition_id === crown.id).acquired_at, isoOf('2026-09-02 08:30:00'));++ const eq = (await inv.equipped([A]))[0].slots;++ assert.deepStrictEqual(Object.keys(eq).sort(), ['live.hat:hat', 'live.name_effect:name_effect']);++ });++++ await check('a second run grants nothing again, and verify finds no difference', async () => {++ const again = await importLive({ live, inv, db, apply: true, log: quiet });++ assert.deepStrictEqual([again.granted, again.alreadyHere], [0, 4]);++ assert.strictEqual(Number(await db.value('SELECT count(*) FROM inventory_instances')), 4);++ const v = await verifyLive({ live, inv, db });++ assert.deepStrictEqual(v.differences, []);++ });++++ await check('verify names a person who differs', async () => {++ await live.exec("INSERT INTO user_cosmetics (user_id, item_id, category, unlocked_at) VALUES (1, 'px_sparkle', 'particle', '2026-09-07 00:00:00')");++ const v = await verifyLive({ live, inv, db });++ assert.deepStrictEqual(v.differences, [{ subject: A, missing: ['px_sparkle'], slots: [] }]);++ });++ } finally {++ await live.close();++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,141 @@++'use strict';++/**++ * The inventory (ADR-054) through its API: Live's kinds and cosmetics are seeded at boot; an issuer grants within its++ * own namespace only, idempotently and within a supply cap; a person sees and equips what they own; the public reads++ * show owned items of published definitions; a batch of equipped sets is one call; every movement writes a ledger row++ * and an event; and nothing anywhere sells, buys, trades or converts an item.++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const LIVE = t.network.serviceToken('live', ['inventory.item.read', 'inventory.item.grant', 'inventory.item.consume', 'inventory.definition.manage', 'inventory.item.list', 'inventory.equip.manage']);++ const QUEST = t.network.serviceToken('quest', ['inventory.item.grant', 'inventory.definition.manage']);++ const NOREAD = t.network.serviceToken('search', []);++ const api = (path, o = {}) => t.get(`/api/v1${path}`, o);++ const valid = (id, body) => { const v = contracts.validate(id, body); assert.ok(v.valid, `${id}: ${JSON.stringify(v.errors)}`); };++ const events = async (type) => (await t.ctx.s.db.many('SELECT envelope FROM event_outbox ORDER BY id')).map((r) => r.envelope).filter((e) => e.event_type === type);++ let rainbow;++ let crown;++ let granted;++++ try {++ await check('boot registers Live\'s five kinds and 70 cosmetics, published, found by Live\'s ids', async () => {++ const kinds = (await api('/kinds')).json().kinds;++ assert.deepStrictEqual(kinds.map((k) => k.id), ['live.chat_tag', 'live.hat', 'live.name_effect', 'live.particle', 'live.voice']);++ for (const k of kinds) valid('inventory.kind@1', k);++ const defs = (await api('/definitions?limit=500')).json().definitions;++ assert.strictEqual(defs.length, 70);++ for (const d of defs) valid('inventory.definition@1', d);++ rainbow = defs.find((d) => (d.aliases || []).includes('fx_rainbow'));++ crown = defs.find((d) => (d.aliases || []).includes('hat_crown'));++ assert.deepStrictEqual([rainbow.kind, rainbow.rarity, rainbow.art.token, rainbow.issuer], ['live.name_effect', 'common', 'name-fx-rainbow', 'service:live']);++ assert.strictEqual(crown.rarity, 'epic');++ // A second boot on the same database seeds nothing again.++ const { seed } = require('../server/inventory/seed');++ assert.deepStrictEqual(await seed(t.ctx.inv, { log: { log() {} } }), { kinds: 5, definitions: 0 });++ });++++ await check('an issuer grants within its namespace, idempotently; a non-stackable item already owned answers the one held', async () => {++ const g = await api('/grants', { bearer: LIVE, json: { definition_id: rainbow.id, subject: alice.subject, idempotency_key: 'grant-0000-0001', reason: 'test' } });++ assert.strictEqual(g.status, 201, g.text);++ granted = g.json().instance;++ valid('inventory.instance@1', granted);++ assert.deepStrictEqual([granted.owner, granted.origin, granted.state], [alice.subject, 'granted', 'owned']);++ const again = await api('/grants', { bearer: LIVE, json: { definition_id: rainbow.id, subject: alice.subject, idempotency_key: 'grant-0000-0001' } });++ assert.deepStrictEqual([again.status, again.json().instance.id, again.json().created], [200, granted.id, false]);++ const other = await api('/grants', { bearer: LIVE, json: { definition_id: rainbow.id, subject: alice.subject, idempotency_key: 'grant-0000-0002' } });++ assert.deepStrictEqual([other.status, other.json().instance.id], [200, granted.id], 'not stackable: the one she has');++ assert.strictEqual((await events('inventory.item.granted')).length, 1);++ const ev = (await events('inventory.item.granted'))[0];++ valid('inventory.item.granted', ev.payload);++ assert.deepStrictEqual([ev.subject, ev.visibility], [{ type: 'user', id: alice.subject }, 'subject']);++ });++++ await check('only the kind\'s issuer defines or grants its items; people and missing capabilities are refused', async () => {++ const wrong = await api('/grants', { bearer: QUEST, json: { definition_id: rainbow.id, subject: bob.subject, idempotency_key: 'quest-0000-0001' } });++ assert.deepStrictEqual([wrong.status, wrong.json().code], [403, 'inventory.not_issuer']);++ const def = await api('/definitions', { bearer: QUEST, json: { kind: 'live.hat', name: 'Quest hat', art: { emoji: '🎩' }, rarity: 'rare', attributes: { tier: 3 } } });++ assert.deepStrictEqual([def.status, def.json().code], [403, 'inventory.not_issuer']);++ const person = await api('/grants', { as: alice, method: 'POST', json: { definition_id: rainbow.id, subject: alice.subject, idempotency_key: 'self-0000-0001' }, headers: { origin: t.base } });++ assert.deepStrictEqual([person.status, person.json().code], [403, 'inventory.not_issuer'], 'a person is not an issuer');++ const noCap = await api('/grants', { bearer: NOREAD, json: { definition_id: rainbow.id, subject: bob.subject, idempotency_key: 'none-0000-0001' } });++ assert.strictEqual(noCap.status, 403);++ const bought = await api('/grants', { bearer: LIVE, json: { definition_id: rainbow.id, subject: bob.subject, idempotency_key: 'buy-0000-0001', origin: 'bought' } });++ assert.deepStrictEqual([bought.status, bought.json().code], [422, 'inventory.bad_origin'], 'nothing is bought (ADR-054 §5)');++ });++++ await check('a capped item numbers its instances and stops at the cap; attributes must fit the kind', async () => {++ const bad = await api('/definitions', { bearer: LIVE, json: { kind: 'live.hat', name: 'Broken', art: { emoji: '🎩' }, rarity: 'rare', attributes: { tier: 99 } } });++ assert.deepStrictEqual([bad.status, bad.json().code], [422, 'inventory.bad_attributes']);++ const made = await api('/definitions', { bearer: LIVE, json: { kind: 'live.hat', name: 'Launch Cap', art: { emoji: '🧢' }, rarity: 'legendary', attributes: { tier: 6, hat_char: '🧢' }, supply_cap: 2, publish: true, aliases: ['hat_launch_cap'] } });++ assert.strictEqual(made.status, 201, made.text);++ const capId = made.json().definition.id;++ assert.strictEqual((await events('inventory.definition.published')).filter((e) => e.payload.definition_id === capId).length, 1);++ const a = await api('/grants', { bearer: LIVE, json: { definition_id: capId, subject: alice.subject, idempotency_key: 'cap-0000-0001' } });++ const b = await api('/grants', { bearer: LIVE, json: { definition_id: capId, subject: bob.subject, idempotency_key: 'cap-0000-0002' } });++ assert.deepStrictEqual([a.json().instance.serial, b.json().instance.serial], [1, 2]);++ const c = await api('/grants', { bearer: LIVE, json: { definition_id: capId, subject: t.network.addUser('carol').subject, idempotency_key: 'cap-0000-0003' } });++ assert.deepStrictEqual([c.status, c.json().code], [409, 'inventory.supply_exhausted']);++ const locked = await api(`/definitions/${capId}`, { bearer: LIVE, method: 'PATCH', json: { rarity: 'common' } });++ assert.deepStrictEqual([locked.status, locked.json().code], [409, 'inventory.published'], 'rarity is fixed once people own it');++ });++++ await check('a person equips what they own, one item per slot; a batch read is one call', async () => {++ const eq = await api('/me/equipped', { as: alice, method: 'PUT', json: { kind: 'live.name_effect', slot: 'name_effect', instance_id: granted.id }, headers: { origin: t.base } });++ assert.strictEqual(eq.status, 200, eq.text);++ valid('inventory.equipped@1', eq.json());++ assert.strictEqual(eq.json().slots['live.name_effect:name_effect'].token, 'name-fx-rainbow');++ const notMine = await api('/me/equipped', { as: bob, method: 'PUT', json: { kind: 'live.name_effect', slot: 'name_effect', instance_id: granted.id }, headers: { origin: t.base } });++ assert.deepStrictEqual([notMine.status, notMine.json().code], [409, 'inventory.not_owned']);++ const wrongSlot = await api('/me/equipped', { as: alice, method: 'PUT', json: { kind: 'live.name_effect', slot: 'hat', instance_id: granted.id }, headers: { origin: t.base } });++ assert.deepStrictEqual([wrongSlot.status, wrongSlot.json().code], [422, 'inventory.bad_slot']);++ const crossSite = await api('/me/equipped', { as: alice, method: 'PUT', json: { kind: 'live.name_effect', slot: 'name_effect', instance_id: null }, headers: { origin: 'https://evil.example' } });++ assert.strictEqual(crossSite.status, 403, 'a cookie write from another site is refused');++ const batch = await api(`/equipped?subjects=${alice.subject},${bob.subject}`);++ valid('inventory.equipped-batch@1', batch.json());++ assert.deepStrictEqual(batch.json().equipped.map((e) => Object.keys(e.slots).length), [1, 0]);++ const forLive = await api('/me/equipped', { bearer: LIVE, method: 'PUT', json: { kind: 'live.name_effect', slot: 'name_effect', instance_id: null }, headers: { 'X-OV-Subject': alice.subject } });++ assert.deepStrictEqual([forLive.status, Object.keys(forLive.json().slots).length], [200, 0], 'Live clears the slot acting for her');++ assert.strictEqual((await events('inventory.item.unequipped')).length, 1);++ });++++ await check('the public inventory shows owned items; the person\'s own read shows every state; consume and revoke', async () => {++ const pub = await api(`/people/${alice.subject}/items`);++ valid('inventory.inventory@1', pub.json());++ assert.strictEqual(pub.json().instances.length, 2);++ const used = await api(`/instances/${granted.id}/consume`, { bearer: LIVE, method: 'POST', json: {} });++ assert.strictEqual(used.json().instance.state, 'consumed');++ assert.strictEqual((await api(`/people/${alice.subject}/items`)).json().instances.length, 1, 'used up: off the public page');++ const own = await api('/me/items', { as: alice });++ assert.deepStrictEqual(own.json().instances.map((i) => i.state).sort(), ['consumed', 'owned']);++ const twice = await api(`/instances/${granted.id}/revoke`, { bearer: LIVE, method: 'POST', json: { reason: 'x' } });++ assert.deepStrictEqual([twice.status, twice.json().code], [409, 'inventory.not_owned']);++ const notIssuer = await api(`/instances/${granted.id}/consume`, { bearer: QUEST, method: 'POST', json: {} });++ assert.strictEqual(notIssuer.status, 403);++ const ledger = await t.ctx.s.db.many('SELECT action FROM inventory_ledger WHERE instance_id = $1 ORDER BY id', [granted.id]);++ assert.deepStrictEqual(ledger.map((r) => r.action), ['granted', 'equipped', 'unequipped', 'consumed']);++ });++++ await check('a presented token must hold inventory.item.read; anyone reads without one', async () => {++ assert.strictEqual((await api('/kinds')).status, 200);++ assert.strictEqual((await api('/kinds', { bearer: NOREAD })).status, 403);++ assert.strictEqual((await api('/kinds', { bearer: LIVE })).status, 200);++ });++++ await check('no route sells, buys, trades or converts an item (ADR-054 §5)', async () => {++ const fs = require('fs');++ const routes = fs.readFileSync(require.resolve('../server/http/api.js'), 'utf8') + fs.readFileSync(require.resolve('../server/http/pages.js'), 'utf8');++ for (const word of ['/buy', '/sell', '/trade', '/market', '/price', '/convert', '/cashout', '/case', '/drop']) assert.ok(!routes.includes(`'${word}`) && !routes.includes(`"${word}`), `no ${word} route`);++ for (const word of ['buy', 'sell', 'trade', 'market']) assert.strictEqual((await api(`/${word}`, { method: 'POST', json: {} })).status, 404, `POST /api/v1/${word} does not exist`);++ assert.ok(!/price|amount|vibes|coins/i.test(fs.readFileSync(require.resolve('../migrations/0001_initial.sql'), 'utf8').replace(/--.*$/gm, '')), 'no money column');++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++/**++ * Every page carries the openvibe-shared boost marker and self-starting script (plan T11), so a++ * same-site click swaps <main> in place without a reload. The marker names the layout's release,++ * which the app sets from openvibe-shared/release (a swap only happens between pages of the same++ * release; across a deploy it is a normal load), the boost tag names #main in data-main, and the++ * shared navbar's sign-in follows the current page through the {path} template.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const MARKER = /<meta name="ov-boost" content="(inventory@[0-9A-Za-z._+-]{1,64})">/;++const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#main" defer><\/script>/;++++(async () => {++ const t = await boot();++ const user = t.network.addUser('kim');++ const release = (await t.get('/release.json')).json().release;++++ await check('every rendered page carries the ov-boost marker (the app\'s release) and the data-main boost script', async () => {++ for (const p of ['/', '/updates']) {++ const r = await t.get(p);++ assert.strictEqual(r.status, 200, p);++ const m = r.text.match(MARKER);++ assert.ok(m, `${p}: no ov-boost marker`);++ assert.strictEqual(m[1], `inventory@${release}`, `${p}: the marker is not the app's release`);++ assert.match(r.text, BOOST, `${p}: no boost script with data-main`);++ assert.match(r.text, /<main id="main"/, `${p}: the page's main is not #main`);++ }++ const signedIn = await t.get('/updates', { as: user });++ assert.strictEqual(signedIn.status, 200);++ assert.match(signedIn.text, MARKER, '/updates signed in: no ov-boost marker');++ assert.match(signedIn.text, BOOST, '/updates signed in: no boost script');++ });++++ await check('the home page head comes from openvibe-shared/shell with the same title, canonical, robots and JSON-LD', async () => {++ const r = await t.get('/');++ assert.strictEqual(r.status, 200);++ const head = r.text.slice(0, r.text.indexOf('</head>'));++ assert.strictEqual((r.text.match(/<title>/g) || []).length, 1, 'exactly one <title>');++ assert.ok(head.includes('<title>OpenVibe.Inventory: Your items, on every OpenVibe site.</title>'), 'home title');++ assert.ok(head.includes('<link rel="canonical" href="https://inventory.openvibe.network/">'), 'canonical');++ assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');++ const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);++ assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');++ for (const tag of ['<meta property="og:title"', 'data-ov-icon="inventory"', '/css/app.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {++ assert.ok(head.includes(tag), `head has ${tag}`);++ }++ assert.ok(r.text.includes('id="ov-footer"'), 'the server-rendered footer');++ assert.ok(r.text.includes('OpenVibeFooter.init(window.__OV_PAGE.footer)'), 'the footer is initialised');++ });++++ await check('the navbar config uses the {path} login template, not the path baked in', async () => {++ const r = await t.get('/updates');++ assert.match(r.text, /"loginUrl":"\/auth\/login\?next=\{path\}"/);++ assert.ok(!/"loginUrl":"\/auth\/login\?next=%2F/.test(r.text), 'loginUrl still has the current path baked in');++ });++++ await check('the home page shows the service name and the tagline server-side, with no local path', async () => {++ const r = await t.get('/');++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.match(r.headers.get('content-type'), /^text\/html/);++ assert.ok(r.text.includes('OpenVibe.Inventory'), 'the service name is on the page');++ assert.ok(r.text.includes('Your items, on every OpenVibe site.'), 'the tagline is on the page');++ assert.ok(r.text.includes('/shared/showcase.css'), 'the showcase stylesheet');++ assert.ok(!r.text.includes('/home/') && !r.text.includes('/mnt/'), 'no local filesystem path on the page');++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,20 @@++'use strict';++// deploy/nginx/inventory.openvibe.network.conf: the shared navbar asks /auth/me on every page view, so the session probe has its own location on++// the API zone; the sign-in routes keep the strict zone (10 a minute). Both answer 429 when limited, not++// nginx's default 503 (the browser check, OpenVibe.Host scripts/browser-check.js, found /auth/me answering++// 503 after about ten quick page loads, which the navbar reads as signed out).++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const conf = fs.readFileSync(path.join(__dirname, '..', ...'deploy/nginx/inventory.openvibe.network.conf'.split('/')), 'utf8');++const block = (loc) => { const m = conf.match(new RegExp(`\\n location ${loc} \\{\\n([\\s\\S]*?)\\n \\}`)); return m && m[1]; };++const probe = block('= /auth/me'), auth = block('/auth/');++assert.ok(probe && auth, 'both locations');++assert.ok(conf.indexOf('location = /auth/me') < conf.indexOf('location /auth/ {'));++assert.match(probe, /limit_req zone=inventory_api burst=30 nodelay;/);++assert.match(auth, /limit_req zone=inventory_auth burst=10 nodelay;/);++for (const b of [probe, auth]) assert.match(b, /limit_req_status 429;/);++const upstream = (b) => (b.match(/proxy_pass (\S+);/) || [])[1];++assert.ok(upstream(probe) && upstream(probe) === upstream(auth), 'the same upstream');++console.log('nginx auth limit: /auth/me on the API zone, 429 when limited');nnew file mode 100644@@@ -0,0 +1,55 @@++'use strict';++/**++ * No X-Internal-Key anywhere (ADR-014: "No developer-facing path ever accepts X-Internal-Key").++ * Code, client scripts, deploy files, the environment template and the manifest must not++ * mention it; the running service never sends it to Network (the Network mock refuses any request++ * that carries one), and a request that presents one gets no special treatment.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./helpers/boot');++++const ROOT = path.join(__dirname, '..');++const PATTERN = /x-internal-key|internal[_-]?key|INTERNAL_API_KEY/i;++++function files(dir) {++ const out = [];++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {++ if (e.name === 'node_modules' || e.name.startsWith('.git')) continue;++ const p = path.join(dir, e.name);++ if (e.isDirectory()) out.push(...files(p)); else out.push(p);++ }++ return out;++}++++(async () => {++ await check('no mention in server/, public/, deploy/, docs/*.json, .env.example, package.json or CI', async () => {++ const targets = [++ ...files(path.join(ROOT, 'server')), ...files(path.join(ROOT, 'public')), ...files(path.join(ROOT, 'deploy')),++ ...(fs.existsSync(path.join(ROOT, 'docs')) ? files(path.join(ROOT, 'docs')).filter((f) => f.endsWith('.json')) : []),++ path.join(ROOT, '.env.example'), path.join(ROOT, 'package.json'), path.join(ROOT, '.github', 'workflows', 'ci.yml'),++ ].filter((f) => fs.existsSync(f));++ assert.ok(targets.length > 15);++ const hits = targets.filter((f) => PATTERN.test(fs.readFileSync(f, 'utf8')));++ assert.deepStrictEqual(hits.map((f) => path.relative(ROOT, f)), []);++ });++++ const t = await boot();++ await check('a whole session (sign-in, every page, the API) never sends an internal key to Network', async () => {++ const u = t.network.addUser('nia');++ for (const p of ['/', '/updates', '/api/v1/ping', '/auth/me']) await t.get(p, { as: u });++ const sent = t.network.requests.filter((r) => r.headers['x-internal-key']);++ assert.strictEqual(sent.length, 0);++ });++++ await check('presenting X-Internal-Key changes nothing: the public routes answer as for anyone', async () => {++ const r = await t.get('/api/v1/ping', { headers: { 'x-internal-key': 'anything' } });++ assert.strictEqual(r.status, 200);++ const me = await t.get('/auth/me', { headers: { 'x-internal-key': 'anything' } });++ assert.deepStrictEqual(JSON.parse(me.text), { user: null }, 'the key signs nobody in');++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,20 @@++'use strict';++// Open redirect regression (WS-R task 5): the sign-in "next" never leaves the site. Browsers drop tab and++// newline characters from a URL and read a backslash as "/", so "/<TAB>/evil.com" is "//evil.com" to them;++// until 2026-09-26 it got through here.++const assert = require('assert');++const { sanitizeNext } = require('../server/auth/sso');++++const config = { baseUrl: 'https://site.openvibe.test', networkUrl: 'https://openvibe.network' };++const home = (v) => {++ const out = String(sanitizeNext(v, config));++ const host = new URL(out.replace(/[\t\n\r]/g, ''), 'https://site.openvibe.test').host;++ return host === 'site.openvibe.test' || host === 'openvibe.network';++};++for (const v of ['//evil.com', '/\\evil.com', '\\\\evil.com', 'https://evil.com', 'http:evil.com', '/\t/evil.com', '/\n/evil.com', '\t//evil.com',++ ' //evil.com', '/\\/evil.com', 'https:/\\evil.com', '///evil.com', 'javascript:alert(1)', '/%09/evil.com']) {++ assert.ok(home(v), `${JSON.stringify(v)} stays on the site (got ${JSON.stringify(sanitizeNext(v, config))})`);++ assert.ok(!/^\s*javascript:/i.test(String(sanitizeNext(v, config))), JSON.stringify(v));++}++assert.strictEqual(sanitizeNext('/settings?tab=a', config), '/settings?tab=a', 'a plain relative path is kept');++console.log('open redirect: all checks passed');nnew file mode 100644@@@ -0,0 +1,64 @@++'use strict';++// Size budgets for inventory.openvibe.network's home page (roadmap WS-T task 1, openvibe-shared/perf-budget): the++// server as it runs (a fresh database), measured without a browser. Budgets sit about 10% above the++// 2026-10-08 measurement (see below); raising one is a decision to state in the commit.++// node test/perf-budget.test.js++const assert = require('assert');++const fs = require('fs');++const net = require('net');++const os = require('os');++const path = require('path');++const { spawn } = require('child_process');++const { measure, check, format } = require('openvibe-shared/perf-budget');++++// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%++// headroom; file counts are the measured counts and external files stay at 0.++const BUDGETS = {++ htmlRawKB: 27.5, // measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)++ htmlBrotliKB: 6.9, // 6.2++ jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)++ jsRawKB: 264, // 239.8++ jsBrotliKB: 62.5, // 56.5++ cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)++ cssRawKB: 23, // 20.5 with the inventory's cards, rarity tiles and hero panel (2026-10-09)++ cssBrotliKB: 5.5, // 4.9++ externalFiles: 0, // 0++};++++const freePort = () => new Promise((resolve) => { const s = net.createServer(); s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); }); });++++(async () => {++ // A database of its own (not the shared dev PGlite in data/pglite, and not whatever the caller's++ // DATABASE_URL names), so the measurement is the server on a fresh database and the run leaves nothing.++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-budget-'));++ const pgliteDir = path.join(dir, 'pglite');++ const port = await freePort();++ const child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {++ cwd: path.join(__dirname, '..'),++ env: {++ ...process.env, PORT: String(port), HOST: '127.0.0.1', NODE_ENV: 'test',++ DATABASE_URL: '', DATABASE_DIRECT_URL: '', VALKEY_URL: '', INVENTORY_PGLITE_DIR: pgliteDir,++ },++ stdio: ['ignore', 'ignore', 'pipe'],++ });++ let stderr = '';++ child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-2000); });++ const base = `http://127.0.0.1:${port}`;++ try {++ let up = false;++ for (let i = 0; i < 600 && !up; i++) {++ up = await fetch(`${base}/api/health`).then((r) => r.ok).catch(() => false);++ if (!up) await new Promise((r) => setTimeout(r, 100));++ }++ assert.ok(up, `the server did not start:\n${stderr}`);++ assert.ok(fs.existsSync(pgliteDir), 'the server did not use the isolated database (INVENTORY_PGLITE_DIR)');++ const m = await measure({ base });++ const over = check(m, BUDGETS);++ assert.deepStrictEqual(over, [], format(m, over));++ console.log(format(m));++ console.log('perf budget: all checks passed');++ } finally {++ child.kill();++ fs.rmSync(dir, { recursive: true, force: true });++ }++})().catch((err) => { console.error(err); process.exitCode = 1; });nnew file mode 100644@@@ -0,0 +1,23 @@++#!/usr/bin/env node++/**++ * Runs every test/*.test.js in its own process and fails if any fails. They use temp PGlite++ * databases and an in-process mock of OpenVibe.Network; none needs the network or a running site.++ *++ * npm test # everything++ * npm test -- login # only files whose name contains one of the words++ * npm test -- --strict # a skipped test fails the run too++ *++ * A test that cannot run something here prints `<label>: skipped (<why>)`: that file is listed with++ * ○ and not counted as passed (openvibe-shared/test-runner).++ */++'use strict';++const { run } = require('openvibe-shared/test-runner');++++// VERBOSE=1 also prints every file's output, then the summary again.++run({ dir: __dirname, timeoutMs: 60000, pad: 34, parallel: 1 }).then((r) => {++ if (process.env.VERBOSE) {++ for (const t of r.results) console.log(`\n── ${t.file} ──\n${t.output.trimEnd()}`);++ console.log(`\n${r.summary}`);++ }++ process.exit(r.exitCode);++}, (err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,115 @@++'use strict';++/**++ * The crawler behind the security suites (roadmap WS-R task 5): lists every route of the booted++ * app (walking Express's router stack, so a route added later is crawled without anyone listing++ * it), fills route parameters with seeded values, and GETs each path as several people, reporting++ * any response whose body or headers carry a value that person must never see.++ * Not a test itself (no .test.js); used with test/helpers/boot.js.++ */++++/** The path an Express 4 layer is mounted at ('' for app-level middleware), or null when it is a pattern. */++function mountPath(layer) {++ if (!layer.regexp || layer.regexp.fast_slash) return '';++ let src = layer.regexp.source.replace(/^\^/, '').replace(/\\\/\?\(\?=\\\/\|\$\)$/i, '').replace(/\/\?\(\?=\/\|\$\)$/i, '');++ let i = 0;++ src = src.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => `:${(layer.keys[i++] || {}).name || 'param'}`);++ src = src.replace(/\\\//g, '/').replace(/\\\./g, '.').replace(/\\-/g, '-');++ return /[\\^$()|[\]*+?]/.test(src) ? null : src;++}++++/** Every route of the booted app (captured from createApp): [{ path, methods }]. */++function listRoutes(app) {++ if (!app || !app._router) throw new Error('no Express app');++ const out = [];++ const walk = (stack, prefix) => {++ for (const layer of stack) {++ if (layer.route) {++ const methods = Object.keys(layer.route.methods).filter((m) => layer.route.methods[m]);++ for (const p of [].concat(layer.route.path)) if (typeof p === 'string') out.push({ path: prefix + p, methods });++ } else if (layer.handle && Array.isArray(layer.handle.stack)) {++ const mp = mountPath(layer);++ if (mp !== null) walk(layer.handle.stack, prefix + mp);++ } else {++ const mp = mountPath(layer);++ if (mp) out.push({ path: prefix + mp, methods: ['_all'] });++ }++ }++ };++ walk(app._router.stack, '');++ const seen = new Set();++ return out.filter((r) => { const k = `${r.methods.join(',')} ${r.path}`; if (seen.has(k)) return false; seen.add(k); return true; });++}++++/** Concrete paths for a template: candidate i of every parameter, for each i (no cross product). */++function expand(template, values) {++ const names = [];++ const t = `/${template.replace(/^\/+/, '')}`.replace(/\*/g, 'x').replace(/:([A-Za-z0-9_]+)\??(\([^)]*\))?/g, (m, n) => { names.push(n); return `:${n}`; });++ if (!names.length) return [t];++ const lists = names.map((n) => values(n));++ const width = Math.max(...lists.map((l) => l.length));++ const paths = new Set();++ for (let i = 0; i < width; i++) {++ let j = 0;++ paths.add(t.replace(/:([A-Za-z0-9_]+)/g, () => { const l = lists[j++]; return encodeURIComponent(String(l[Math.min(i, l.length - 1)])); }));++ }++ return [...paths];++}++++/** Every GET path: each GET route expanded, also with `query` appended, plus `extra`. */++function getPaths(app, values, { query = '', extra = [] } = {}) {++ const paths = new Set();++ for (const r of listRoutes(app)) {++ if (!r.methods.includes('get') && !r.methods.includes('_all')) continue;++ for (const p of expand(r.path, values)) {++ paths.add(p);++ if (query && !p.includes('?')) paths.add(`${p}?${query}`);++ }++ }++ for (const p of extra) paths.add(p);++ return [...paths];++}++++/** Which of `needles` ({ label: value }) a response carries: [{ label, where }]. */++function leaks(res, needles) {++ const found = [];++ const headerText = [...(res.headers && typeof res.headers.entries === 'function' ? res.headers.entries() : Object.entries(res.headers || {}))].map(([k, v]) => `${k}: ${v}`).join('\n');++ for (const [label, value] of Object.entries(needles)) {++ if (!value) continue;++ if (res.text && res.text.includes(value)) found.push({ label, where: 'body' });++ if (headerText.includes(value)) found.push({ label, where: 'headers' });++ }++ return found;++}++++/** A fresh client address per request (TRUST_PROXY is on in tests): the per-address rate limits are not what is being read. */++let seq = 0;++function nextAddress() { seq++; return `10.${(seq >> 16) & 255}.${(seq >> 8) & 255}.${seq & 255}`; }++++/**++ * GET every path as every person ({ who: user | token | null }) through t.get (a user object signs++ * in with the session cookie, a string is a bearer token). `needlesFor(who)` names what that person++ * must never see. Resolves { found, answered, statuses }.++ */++async function crawl(t, paths, people, needlesFor, { concurrency = 6 } = {}) {++ const found = [];++ const statuses = {};++ let answered = 0;++ for (const [who, as] of Object.entries(people)) {++ const needles = needlesFor(who);++ const queue = [...paths];++ await Promise.all(Array.from({ length: concurrency }, async () => {++ while (queue.length) {++ const p = queue.shift();++ let r;++ try { r = await t.get(p, { ...(as ? { as } : {}), headers: { 'x-forwarded-for': nextAddress() } }); } catch (e) { r = { status: 0, text: '', headers: {} }; }++ if (r.status) answered++;++ const cls = r.status ? `${String(r.status)[0]}xx` : 'none';++ statuses[cls] = (statuses[cls] || 0) + 1;++ for (const l of leaks(r, needles)) found.push(`${who}: GET ${p} → ${r.status} carries ${l.label} in its ${l.where}`);++ }++ }));++ }++ return { found, answered, statuses };++}++++module.exports = { listRoutes, expand, getPaths, leaks, crawl, nextAddress };nnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++/**++ * The service's own secrets never leave in a response, an event or a log line (roadmap WS-R task 5, the++ * internal-secret class). Its environment holds one: its Network OAuth client secret, a sentinel here. Every route++ * the booted app has (listed from Express's router stack) is requested as anonymous, a signed-in person and a Network++ * admin with real and nonsense ids, plus the sign-in callback with a forged code (it posts the client secret to++ * Network), unknown paths and every write route with a broken body. No body or header may carry it, nor may the++ * database or the captured log lines.++ *++ * node test/security-secrets.test.js++ */++const assert = require('assert');++const appModule = require('../server/app');++let capturedApp = null;++const realCreate = appModule.createApp;++appModule.createApp = async (...a) => { const built = await realCreate(...a); capturedApp = built.app; return built; };++const { boot, check, done } = require('./helpers/boot');++const { getPaths, crawl, listRoutes, expand, leaks, nextAddress } = require('./security-crawl');++++const SECRETS = { OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-oauth-client' };++++(async () => {++ const t = await boot({ env: SECRETS });++ const dev = t.network.addUser('dev');++ const staff = t.network.addUser('root', { role: 'admin' });++++ await check('every GET route, as three people, with real and nonsense ids: no sentinel', async () => {++ const nonsense = ['nope', "'\"<x>", 'x'.repeat(300)];++ const values = () => ['inventory-id', ...nonsense];++ const paths = getPaths(capturedApp, values, {++ query: 'q=x&code=forged&state=forged&next=%2F',++ extra: ['/api/health', '/api/ready', '/metrics', '/release.json', '/auth/callback?code=forged&state=forged', '/callback?code=forged&state=forged', '/api/nope', '/nope', '/.env', '/api/%'],++ });++ const r = await crawl(t, paths, { anonymous: null, dev, staff }, () => SECRETS);++ assert.ok(r.answered >= paths.length * 2, `${r.answered} answers`);++ assert.deepStrictEqual(r.found, []);++ });++++ await check('every write route with a broken body, anonymous and as the developer: no sentinel', async () => {++ const found = [];++ const values = () => ['x'];++ for (const route of listRoutes(capturedApp)) {++ for (const method of route.methods.filter((m) => ['post', 'put', 'patch', 'delete'].includes(m))) {++ for (const p of expand(route.path, values)) {++ for (const as of [null, dev]) {++ const r = await t.get(p, { method: method.toUpperCase(), ...(as ? { as } : {}), body: '{"broken": ', headers: { 'content-type': 'application/json', 'x-forwarded-for': nextAddress() } });++ for (const l of leaks(r, SECRETS)) found.push(`${method} ${p} → ${r.status} carries ${l.label}`);++ }++ }++ }++ }++ assert.deepStrictEqual(found, []);++ });++++ await check('the database and the captured log lines carry no sentinel', async () => {++ const text = await t.dbDump() + t.logs();++ for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `${k} in the database or the logs`);++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++/**++ * The inventory_at session cookie must hold a Network SESSION token (audience openvibe.network, a++ * person), not just anything the Network signed. The Network signs FedCM assertions (audience =++ * the relying party's origin, any https://*.openvibe.* origin), app/service tokens and internal++ * tokens with the same key and issuer; none of them is a session here.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { signJwt } = require('./helpers/mocks');++++(async () => {++ const s = await boot();++ const staff = s.network.addUser('stafferson');++ const APP = 'app_01JABCDEFGHJKMNPQRSTVWXYZ0';++++ const now = () => Math.floor(Date.now() / 1000);++ // What POST /fedcm/assertion hands to ANY page on an owned zone the person continues on.++ const fedcm = () => signJwt({++ iss: s.network.url, sub: staff.id, id: staff.id, subject_id: staff.subject, username: staff.username, display_name: staff.username,++ nonce: null, typ: 'fedcm', jti: 'x', aud: 'https://tenant.openvibe.host', iat: now(), exp: now() + 300,++ }, s.network.privatePem);++++ await check('a FedCM assertion is not a session (it signs nobody in)', async () => {++ const cookie = `inventory_at=${fedcm()}`;++ const me = await s.get('/auth/me', { cookie });++ assert.strictEqual(me.status, 401, 'an assertion for another site is not a session here');++ const page = await s.get('/', { cookie });++ assert.ok(!page.text.includes(`Signed in as <strong>${staff.username}`), 'the page shows nobody signed in');++ });++++ await check('an app token is not a session', async () => {++ const token = signJwt({ iss: s.network.url, sub: `app:${APP}`, actor_type: 'app', aud: ['openvibe.network'], cap: ['x.y.z'], subject_id: staff.subject, iat: now(), exp: now() + 300 }, s.network.privatePem);++ const me = await s.get('/auth/me', { cookie: `inventory_at=${token}` });++ assert.strictEqual(me.status, 401);++ });++++ await check('/auth/me: a guest is signed out (200 { user: null }); a session cookie that fails is 401', async () => {++ const guest = await s.get('/auth/me');++ assert.strictEqual(guest.status, 200, 'no session cookie at all: not an error');++ assert.deepStrictEqual(JSON.parse(guest.text), { user: null });++ assert.strictEqual(guest.headers.get('cache-control'), 'private, no-store');++ assert.strictEqual((await s.get('/auth/me', { cookie: 'inventory_at=garbage' })).status, 401, 'a present but invalid access cookie');++ assert.strictEqual((await s.get('/auth/me', { cookie: 'inventory_rt=stale' })).status, 401, 'a refresh cookie alone is a session that did not resolve');++ assert.strictEqual((await s.get('/auth/me', { as: staff })).status, 200);++ });++++ await check('a real Network session token signs the person in', async () => {++ const me = await s.get('/auth/me', { as: staff });++ assert.strictEqual(me.status, 200, me.text.slice(0, 200));++ assert.strictEqual(JSON.parse(me.text).user.username, staff.username);++ });++++ await check('another site cannot sign a person out; this site can', async () => {++ const cross = await s.get('/auth/logout', { as: staff, headers: { 'sec-fetch-site': 'cross-site' } });++ assert.strictEqual(cross.status, 200, 'a confirm page, not a sign-out');++ assert.ok(!/inventory_at=;|inventory_at=deleted/i.test(String(cross.headers.get('set-cookie') || '')), 'the session cookie is kept');++ assert.match(cross.text, /method="post"/);++ const crossPost = await s.get('/auth/logout', { as: staff, method: 'POST', headers: { 'sec-fetch-site': 'cross-site' } });++ assert.strictEqual(crossPost.status, 403);++ const same = await s.get('/auth/logout?next=/updates', { as: staff, headers: { 'sec-fetch-site': 'same-origin' } });++ assert.strictEqual(same.status, 303);++ assert.strictEqual(same.headers.get('location'), '/updates');++ assert.match(String(same.headers.get('set-cookie') || ''), /inventory_at=;/);++ });++++ await s.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,56 @@++'use strict';++/**++ * openvibe-sdk/service (plan T16/J2): the entry point's graceful stop runs its close steps in order++ * (the timers clear, the JWKS refresher stops, then the store closes: the order the manifest's++ * shutdown.drains lists) and resolves exit 0; a second signal while stopping is a no-op.++ */++const assert = require('assert');++const http = require('http');++const { check, done } = require('./helpers/boot');++const { createLifecycle } = require('../server/index');++++const listen = (server) => new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));++++(async () => {++ await check('the entry point stop clears the timers, stops the JWKS refresher, then closes the store, then exits 0', async () => {++ const steps = [];++ const ctx = {++ keys: { client: { stop: () => { steps.push('keys.stop'); } } },++ s: { close: async () => { steps.push('store.close'); } },++ };++ const server = http.createServer((req, res) => res.end('ok'));++ await listen(server);++++ const exits = [];++ const timer = setInterval(() => steps.push('tick'), 1_000_000);++ const lifecycle = createLifecycle({ server, ctx, exit: (code) => exits.push(code), signals: false, timers: [timer] });++ const code = await lifecycle.stop('SIGTERM');++++ assert.strictEqual(code, 0);++ assert.deepStrictEqual(exits, [0]);++ assert.deepStrictEqual(steps, ['keys.stop', 'store.close']);++ assert.strictEqual(server.listening, false);++ });++++ await check('a second signal while stopping is a no-op', async () => {++ let stops = 0; let closes = 0; let exits = 0;++ const ctx = {++ keys: { client: { stop: () => { stops += 1; } } },++ s: { close: async () => { closes += 1; } },++ };++ const server = http.createServer((req, res) => res.end('ok'));++ await listen(server);++++ const lifecycle = createLifecycle({ server, ctx, exit: () => { exits += 1; }, signals: false });++ const first = lifecycle.stop('SIGTERM');++ const second = lifecycle.stop('SIGINT');++++ assert.strictEqual(first, second);++ assert.deepStrictEqual(await Promise.all([first, second]), [0, 0]);++ assert.strictEqual(stops, 1);++ assert.strictEqual(closes, 1);++ assert.strictEqual(exits, 1);++ });++++ done();++})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+# OpenVibe.Inventory — environment (production: /etc/openvibe/inventory.env). Names only; never commit values.
+
+NODE_ENV=production
+PORT=5030
+HOST=127.0.0.1
+BASE_URL=https://inventory.openvibe.network
+TRUST_PROXY=2
+# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh inventory: the runtime role through PgBouncer,
+# the owner on a direct connection (migrations), and Valkey (shared limit counters).
+# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
+DATABASE_URL=
+DATABASE_DIRECT_URL=
+VALKEY_URL=
+VALKEY_PREFIX=ov:inventory:
+# Per-caller limits (server/http/caller-limits.js): requests one caller may make to the API per minute and per hour.
+#INVENTORY_LIMITS_MINUTE=120
+#INVENTORY_LIMITS_HOUR=3000
+
+# OpenVibe.Network: SSO (OAuth client `inventory`, PKCE S256) and its JWKS; app tokens for audience inventory.openvibe.network.
+OV_NETWORK_URL=https://openvibe.network
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+OV_OAUTH_CLIENT_ID=inventory
+OV_OAUTH_CLIENT_SECRET=
+# OV_OAUTH_REDIRECT_URI=https://inventory.openvibe.network/auth/callback
+# INVENTORY_AUDIENCE=inventory.openvibe.network
+
+# optional: defaults to true in production
+# COOKIE_SECURE=true
+
+# OpenVibe.Events: the outbox relay publishes inventory.item.* and inventory.definition.published when EVENTS_URL and
+# OV_OAUTH_CLIENT_SECRET are set (otherwise events wait in event_outbox). INVENTORY_EVENTS_SECRET signs the deliveries
+# to POST /internal/events (account export and deletion; comma-separated for rotation, 32+ characters each). Unset: the
+# route answers 503 and no subscription is created.
+#EVENTS_URL=http://127.0.0.1:4300
+#EVENTS_RELAY_INTERVAL_MS=2000
+#INVENTORY_EVENTS_SECRET=
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ # install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).
+ test:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
+ with:
+ syntax-dirs: server scripts test
+ contracts-service: inventory
+ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
+ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.
+ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
+ # Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
+ security:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+node_modules/
+data/
+dist/
+.env
+.env.*
+!.env.example
+*.log
+*.pem
+*.key
+!server/data/
+node_modules
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+# Changelog
+
+What changed in OpenVibe.Inventory, newest first. Each site also publishes its patch notes at /updates.
+
+## 0.2.0 — 2026-10-09
+
+- **The inventory (ADR-054, plan T21 step 2).**
+ - **Storage:** kinds, definitions, instances, the equipped set and an append-only ledger
+ (`migrations/0001_initial.sql`).
+ - **Code:** the domain in `server/inventory/store.js`, the API in `server/http/api.js` (seven capabilities) and the
+ pages: home, `/items`, `/items/:id`, `/me` and `/u/:subject`.
+ - **Seed:** Live's five kinds and 70 cosmetics at boot.
+ - **Events and account data:** `inventory.*` events through the outbox, and account export and deletion at
+ `/internal/events`.
+ - **Migration:** `scripts/import-live.js` moves Live's unlocks and equipped slots here and verifies them row for row.
+ - **The money rule:** no route or column sells, buys, trades or converts an item, and a test holds that.
+
+## 0.1.0 — 2026-10-08
+
+- **First release:** the service starts from the OpenVibe skeleton — sign-in with OpenVibe.Network (OAuth 2 + PKCE), server-rendered pages through the OpenVibe Frame, the `/api/v1` mount with per-caller limits, crawl artifacts, PostgreSQL migrations, the deploy files and the test suite.
new file mode 100644
new file mode 100644
@@ -0,0 +1,661 @@
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+# OpenVibe.Inventory
+
+> Your items, on every OpenVibe site.
+
+**Status:** alpha (ADR-054, plan track T21 step 2). The authority, its API, pages and Live's migration script are built
+and tested; Live switches to it next.
+**Domain:** `inventory.openvibe.network` · **Port:** 5030 · **Service id:** `inventory` · **Env prefix:** `INVENTORY`
+**License:** AGPL-3.0 (same as every OpenVibe service).
+
+One inventory for an OpenVibe account, the way Steam's works across games. Sites, games and apps give items for what
+people do; people keep them in one place and wear them on every site that shows their kind.
+
+## The model (ADR-054)
+
+- **Kind** (`live.name_effect`, `live.hat`, …): what its items carry (a JSON Schema), where one is equipped (slots) and
+ which surfaces render it (chat, overlays, the profile, `game:<id>`). A new kind is a contract plus a renderer, never a
+ new table. Kinds are registered at boot from [server/data/kinds.json](server/data/kinds.json).
+- **Definition** (`itd_…`): one item of a kind, with a name, art (a Media image, a renderer token, an emoji), an honest
+ rarity (common, uncommon, rare, epic, legendary), attributes valid against the kind, its issuer and an optional
+ supply cap. Rarity, attributes and the cap are fixed once it is published.
+- **Instance** (`inv_…`): one definition owned by one person, with its origin (granted, earned, migrated), its state
+ (owned, consumed, revoked) and, under a cap, a serial number.
+- **Equipped:** one instance per kind and slot.
+- **Ledger:** an append-only row for every movement, with who did it and why.
+
+Only a kind's issuer defines and grants its items: `service:live` for `live.*`. **Nothing is sold, bought, traded or
+converted into OpenCoins or Vibes**, and there are no paid random rewards, until a later ADR covers the legal basis and
+Billing (ADR-054 §5). A test fails if a route or a column for that appears.
+
+## API (`/api/v1`, problem+json errors)
+
+| Route | Capability | |
+|---|---|---|
+| `GET /kinds`, `/kinds/:id` | `inventory.item.read` (public) | the kinds |
+| `GET /definitions?kind=&issuer=`, `/definitions/:id` | `inventory.item.read` (public) | published items |
+| `GET /people/:subject/items`, `/people/:subject/equipped` | `inventory.item.read` (public) | a person's owned items and what they wear |
+| `GET /equipped?subjects=a,b,…` | `inventory.item.read` (public) | up to 100 equipped sets in one call (chat renders) |
+| `GET /me/items` | `inventory.item.list` | the caller's own items, every state |
+| `PUT /me/equipped` | `inventory.equip.manage` | equip or clear a slot |
+| `POST /grants` | `inventory.item.grant` | an issuer grants an instance (idempotent per key; 409 `inventory.supply_exhausted`) |
+| `POST /instances/:id/consume`, `/revoke` | `inventory.item.consume` | an issuer uses up or takes back an instance |
+| `POST /definitions`, `PATCH /definitions/:id` | `inventory.definition.manage` | an issuer's items |
+
+A person acts for themself (their token or this site's session; a cookie write must come from this site). A service
+acting for a person sends `X-OV-Subject: usr_…` and holds the route's capability. A public read needs no token; a
+token that is presented must hold `inventory.item.read`.
+
+**Events** (openvibe-sdk outbox): `inventory.item.granted`, `.consumed`, `.revoked`, `.equipped`, `.unequipped` and
+`inventory.definition.published`. **Account export and deletion** (ADR-033) arrive at the loopback
+`POST /internal/events` (`INVENTORY_EVENTS_SECRET`; subscriptions created at boot). The export carries the person's
+items, equipped set and history. The deletion removes their items and equipped set and keeps the ledger rows without
+them, so supply counts stay true.
+
+## Pages
+
+`/` (what it is), `/items` (every item by kind), `/items/:id` (one item: where it shows, who gives it, how many exist),
+`/me` (your items; Wear and Take off are plain forms), `/u/:subject` (a person's public inventory) and `/updates`.
+Every page works without JavaScript.
+
+## Moving Live's cosmetics (ADR-054 §8)
+
+Live's 70 cosmetics are seeded as definitions issued by `service:live`, with Live's item ids kept as aliases. Then:
+
+```bash
+node --env-file=/etc/openvibe/inventory.env scripts/import-live.js --live-env /etc/openvibe/live.env # dry run
+node --env-file=/etc/openvibe/inventory.env scripts/import-live.js --live-env /etc/openvibe/live.env --apply # convert
+node --env-file=/etc/openvibe/inventory.env scripts/import-live.js --live-env /etc/openvibe/live.env --verify # compare
+```
+
+The script only reads Live's database, and never prints its URL. `--apply` keeps unlock times and grants nothing twice.
+`--verify` compares every person's items and slots, row for row. Live then reads and writes through this API
+(`INVENTORY_AUTHORITY=inventory`), and its tables are dropped after the N-1 window.
+
+## Configuration
+
+See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET` (the `inventory` OAuth client on the
+Network), `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`. The database is the only required readiness check; the
+Network signing key, the OAuth client and Valkey are optional (the service says so, per check, on `/api/ready`).
+
+## Development
+
+```bash
+npm install
+fnm exec --using=22 npm test # every test/*.test.js, on temp PGlite databases with a mock Network
+fnm exec --using=22 npm run dev # http://localhost:5030
+```
+
+Without `DATABASE_URL` development uses an embedded PGlite database in `data/pglite` (one process only). `npm run
+test:pg` runs the same suite through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflows/ci.yml)).
+
+## Deploy (for the lead)
+
+- **Deploy:** `sudo ovhost deploy inventory` on the host (git checkout at `/opt/inventory.openvibe.network`, unit
+ `openvibe-inventory.service` on 127.0.0.1:5030, env `/etc/openvibe/inventory.env`, database `ov_inventory` on the data role).
+- **nginx:** [deploy/nginx/inventory.openvibe.network.conf](deploy/nginx/inventory.openvibe.network.conf), installed with `ov-vhost-install`.
+- **Rollback:** ovhost puts the previous sha back by itself when `/api/ready` does not answer after the restart.
+- **Network:** OAuth client `inventory`; grants `events.event.publish` and `events.subscription.manage` (openvibe.events),
+ then the account grants; the issuers' grants (`live`: `inventory.item.grant`, `inventory.item.consume`,
+ `inventory.definition.manage`, `inventory.item.list`, `inventory.equip.manage`, `inventory.item.read`).
+- The vhost uses the `openvibe.network` wildcard certificate.
+
+## Security (threat notes)
+
+Reporting a vulnerability: [SECURITY.md](SECURITY.md).
+
+- Session tokens are httpOnly cookies; a FedCM assertion or an app or service token is never a session.
+- Secrets live only in the env file; only environment variable names appear in code and docs, and no secret is logged.
+- Request bodies are never logged.
+- Nothing in a request may decide a URL this service fetches: a caller's URL goes to OpenVibe.Tools, whose own guard
+ decides what may be fetched.
+
+---
+
+Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
+
+<!-- versions:start -->
+- openvibe-contracts: v0.121.0
+- openvibe-sdk: v0.37.0
+- openvibe-shared: v2.15.0
+<!-- versions:end -->
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+# Security policy
+
+OpenVibe is run in the open, and we want to hear about security problems before anyone else does.
+
+## Reporting a vulnerability
+
+Email **[email protected]** with "security" in the subject. Please include what you found, where (URL,
+repository, file), how to reproduce it, and what an attacker could do with it. Do not open a public GitHub issue for
+a vulnerability, and do not test against other people's accounts, data or streams.
+
+We reply within 7 days, keep you updated while we fix it, and credit you when the fix ships if you would like.
+
+## Scope
+
+Every OpenVibe service and site (openvibe.live, openvibe.network, openvibe.tools, openvibe.media, openvibe.community,
+openvibe.games and the other openvibe.* domains) and every repository under github.com/OpenVibers. Machine-readable
+contact details are at `/.well-known/security.txt` on each site.
+
+## Supported versions
+
+Only the current `main` branch, which is what runs in production, receives fixes.
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
+{
+ "stage": "alpha",
+ "code": true,
+ "wave": "W20",
+ "plan": "Plan track T21 (the OpenVibe inventory), ADR-054: step 2, the authority with Live's cosmetics as the first kinds",
+ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
+ "port": "5030",
+ "domain": "inventory.openvibe.network",
+ "contracts": "openvibe-contracts v0.121.0",
+ "sdk": "openvibe-sdk v0.37.0",
+ "shared": "openvibe-shared v2.15.0",
+ "packages": [
+ "openvibe-contracts v0.121.0",
+ "openvibe-sdk v0.37.0",
+ "openvibe-shared v2.15.0"
+ ],
+ "summary": "OpenVibe.Inventory — one inventory for an OpenVibe account (ADR-054). Item kinds (a contract plus a renderer per surface), definitions, instances, the equipped set and an append-only ledger in its own PostgreSQL database. Issuers (services, apps) grant only within their namespace; people see and equip what they own; every movement is a ledger row and an inventory.* event. Nothing is sold, bought, traded or converted. Live's name effects, particles, hats and voices are the first kinds (70 definitions seeded at boot), and scripts/import-live.js moves Live's unlocks and equipped slots here, row for row.",
+ "works": [
+ "Kinds and definitions seeded at boot from data/kinds.json and data/live-definitions.json (Live's catalog, its item ids kept as aliases)",
+ "Grants by an issuer within its namespace: idempotent per key, supply caps with serial numbers, a non-stackable item answers the one already owned; consume and revoke",
+ "Equip one item per slot (a person, or a service acting for them with X-OV-Subject); a batch read of up to 100 equipped sets in one query",
+ "Public reads (kinds, definitions, a person's inventory and equipped set) and the person's own full inventory",
+ "Definitions created and edited by their issuer; rarity, attributes and supply fixed once published; attributes validated against the kind's schema",
+ "Pages: home, every item by kind, one item, your inventory with plain equip forms, a person's public inventory, /updates",
+ "Events (inventory.item.granted/consumed/revoked/equipped/unequipped, inventory.definition.published) through the openvibe-sdk outbox",
+ "Account export and deletion (ADR-033) at the loopback POST /internal/events, subscriptions created at boot",
+ "scripts/import-live.js: dry run, --apply (origin migrated, unlock times kept, idempotent) and --verify (row for row)"
+ ],
+ "notYet": [
+ "Live reading and writing through Inventory (INVENTORY_AUTHORITY), then Live's tables dropped (ADR-054 §8)",
+ "Profiles on Network showing the showcase; equip in Chat, Community and Games",
+ "Creator submissions and staff review (the Workshop, ADR-054 §6)",
+ "Trading and a market: not before a money ADR (ADR-054 §5)",
+ "Account merge (ADR-029): instances moving to the surviving subject"
+ ],
+ "capabilitiesRegistered": true,
+ "serviceManifestRegistered": true,
+ "deployed": false,
+ "placeholderInSites": false,
+ "features": [
+ "item kinds",
+ "definitions",
+ "grants",
+ "equip",
+ "public inventory",
+ "Live migration",
+ "account export and deletion"
+ ],
+ "updated": "2026-10-09"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+## ═══════════════════════════════════════════════════════════════
+## inventory.openvibe.network — Nginx configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/inventory.openvibe.network.conf (ov-vhost-install) — keep them in sync and reload
+## nginx after changing it. The Node app (port 5030, systemd unit openvibe-inventory) renders every page and serves
+## the API; nginx terminates TLS with the openvibe.network wildcard certificate (*.openvibe.network: a repo vhost uses
+## the certificate that really covers its name, or Cloudflare answers 526), rate-limits sign-in, the API and the equip
+## form, keeps /internal/ (OpenVibe.Events deliveries) off the internet, and compresses text. There is no www. name:
+## the wildcard does not cover a second level.
+##
+## No proxy_cache: the app decides cacheability per response (public for the pages rendered for anonymous
+## visitors; private, no-store for everything personal). Never log request bodies here.
+## ═══════════════════════════════════════════════════════════════
+
+limit_req_zone $binary_remote_addr zone=inventory_auth:10m rate=10r/m;
+limit_req_zone $binary_remote_addr zone=inventory_api:10m rate=5r/s;
+limit_req_zone $binary_remote_addr zone=inventory_forms:10m rate=1r/s;
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name inventory.openvibe.network;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://inventory.openvibe.network$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name inventory.openvibe.network;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.network/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.network/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/inventory.openvibe.network.access.log;
+ error_log /var/log/nginx/inventory.openvibe.network.error.log;
+
+ # The API's JSON body cap is 64 KB; leave room for the headers.
+ client_max_body_size 128k;
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+ gzip on;
+ gzip_proxied any;
+ gzip_vary on;
+ gzip_comp_level 5;
+ gzip_min_length 1024;
+ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;
+
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # ── Network SSO session endpoints ───────────────────────
+ # The navbar's session probe runs on every page view: it gets the API's budget, not sign-in's.
+ location = /auth/me {
+ limit_req zone=inventory_api burst=30 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5030;
+ }
+
+ location /auth/ {
+ limit_req zone=inventory_auth burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5030;
+ }
+
+ # ── API ─────────────────────────────────────────────────
+ location /api/ {
+ limit_req zone=inventory_api burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5030;
+ }
+
+ # ── The equip form (POST /me/equip) ─────────────────────
+ location = /me/equip {
+ limit_req zone=inventory_forms burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5030;
+ }
+
+ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5030/metrics) ──
+ location = /metrics { return 404; }
+
+ # ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──
+ # Events posts to http://127.0.0.1:5030/internal/events directly; never proxied from outside (ADR-033).
+ location /internal/ { return 404; }
+
+ # ── Pages, docs, robots.txt, sitemap, static assets ─────
+ location / {
+ proxy_pass http://127.0.0.1:5030;
+ }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+[Unit]
+Description=OpenVibe.Inventory — Your items, on every OpenVibe site.
+# Network (sign-in, JWKS) should be up first, but OpenVibe.Inventory degrades honestly without it: the pages and the API's
+# public routes serve; sign-in and the guarded routes wait for the signing key (see /api/ready).
+After=network.target postgresql.service pgbouncer.service valkey-server.service
+Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/inventory.openvibe.network
+EnvironmentFile=/etc/openvibe/inventory.env
+Environment=NODE_ENV=production
+Environment=PORT=5030
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/inventory.openvibe.network/server/index.js
+Restart=always
+RestartSec=5
+KillSignal=SIGTERM
+# In-flight requests get the 10 s drain.
+TimeoutStopSec=15
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+# OpenVibe.Inventory's own database is PostgreSQL on the host's data role; the code tree stays read-only.
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -0,0 +1,128 @@
+-- phase: expand
+-- OpenVibe.Inventory (ADR-054): item kinds, definitions, instances, the equipped set and the append-only ledger, plus
+-- the grant receipts, the event outbox and the account-data receipts. Nothing written here is ever a credential, a key
+-- or a token. Money never enters this database: nothing is sold, bought, traded or converted (ADR-054 §5).
+
+-- A kind: what its items carry (attribute_schema, a JSON Schema), where one can be equipped (slots) and which surfaces
+-- render it (inventory.kind@1). issuer owns the kind's namespace (service:live for live.*).
+CREATE TABLE inventory_kinds (
+ id text COLLATE "C" PRIMARY KEY, -- live.name_effect
+ name text NOT NULL,
+ description text,
+ slots jsonb NOT NULL DEFAULT '[]',
+ surfaces jsonb NOT NULL DEFAULT '[]',
+ attribute_schema jsonb NOT NULL DEFAULT '{"type":"object"}',
+ stackable boolean NOT NULL DEFAULT false,
+ issuer text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL
+);
+
+-- A definition: one item of a kind (inventory.definition@1).
+CREATE TABLE inventory_definitions (
+ id text COLLATE "C" PRIMARY KEY, -- itd_<ULID>
+ kind text COLLATE "C" NOT NULL REFERENCES inventory_kinds(id),
+ name text NOT NULL,
+ description text,
+ art jsonb NOT NULL,
+ rarity text NOT NULL CHECK (rarity IN ('common', 'uncommon', 'rare', 'epic', 'legendary')),
+ attributes jsonb NOT NULL DEFAULT '{}',
+ issuer text COLLATE "C" NOT NULL, -- service:live | app:app_… | user:usr_…
+ supply_cap integer CHECK (supply_cap IS NULL OR supply_cap > 0),
+ supply_issued integer NOT NULL DEFAULT 0 CHECK (supply_issued >= 0),
+ status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'in_review', 'published', 'retired')),
+ credit_subject text COLLATE "C",
+ credit_name text,
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL,
+ published_at text COLLATE "C",
+ CHECK (supply_cap IS NULL OR supply_issued <= supply_cap)
+);
+CREATE INDEX inventory_definitions_kind ON inventory_definitions (kind, status, name);
+CREATE INDEX inventory_definitions_issuer ON inventory_definitions (issuer, created_at DESC);
+
+-- Earlier ids of a definition, per issuer (Live's fx_rainbow): how Live and the migration find an item.
+CREATE TABLE inventory_definition_aliases (
+ issuer text COLLATE "C" NOT NULL,
+ alias text COLLATE "C" NOT NULL,
+ definition_id text COLLATE "C" NOT NULL REFERENCES inventory_definitions(id) ON DELETE CASCADE,
+ PRIMARY KEY (issuer, alias)
+);
+
+-- An instance: one definition owned by one person (inventory.instance@1).
+CREATE TABLE inventory_instances (
+ id text COLLATE "C" PRIMARY KEY, -- inv_<ULID>
+ definition_id text COLLATE "C" NOT NULL REFERENCES inventory_definitions(id),
+ owner_subject text COLLATE "C" NOT NULL, -- usr_…
+ origin text NOT NULL CHECK (origin IN ('granted', 'earned', 'migrated')),
+ state text NOT NULL DEFAULT 'owned' CHECK (state IN ('owned', 'consumed', 'revoked')),
+ attributes jsonb NOT NULL DEFAULT '{}',
+ serial integer CHECK (serial IS NULL OR serial > 0),
+ acquired_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL
+);
+CREATE INDEX inventory_instances_owner ON inventory_instances (owner_subject, acquired_at DESC, id DESC);
+CREATE INDEX inventory_instances_definition ON inventory_instances (definition_id, owner_subject) WHERE state = 'owned';
+
+-- One grant per (issuer, idempotency key): a repeat answers the instance already granted.
+CREATE TABLE inventory_grants (
+ issuer text COLLATE "C" NOT NULL,
+ idempotency_key text COLLATE "C" NOT NULL,
+ instance_id text COLLATE "C" NOT NULL REFERENCES inventory_instances(id) ON DELETE CASCADE,
+ created_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (issuer, idempotency_key)
+);
+
+-- What a person has equipped: one instance per (kind, slot).
+CREATE TABLE inventory_equipped (
+ owner_subject text COLLATE "C" NOT NULL,
+ kind text COLLATE "C" NOT NULL,
+ slot text COLLATE "C" NOT NULL,
+ instance_id text COLLATE "C" NOT NULL REFERENCES inventory_instances(id) ON DELETE CASCADE,
+ updated_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (owner_subject, kind, slot)
+);
+CREATE INDEX inventory_equipped_instance ON inventory_equipped (instance_id);
+
+-- Every movement, append-only: granted, earned, migrated, equipped, unequipped, consumed, revoked, erased. A deleted
+-- account's rows keep their counts and lose their subject (ADR-054 §7).
+CREATE TABLE inventory_ledger (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ at text COLLATE "C" NOT NULL,
+ action text NOT NULL,
+ owner_subject text COLLATE "C",
+ instance_id text COLLATE "C",
+ definition_id text COLLATE "C",
+ actor text COLLATE "C" NOT NULL, -- user:usr_… | service:… | app:app_…
+ reason text,
+ detail jsonb NOT NULL DEFAULT '{}'
+);
+CREATE INDEX inventory_ledger_owner ON inventory_ledger (owner_subject, id DESC);
+CREATE INDEX inventory_ledger_instance ON inventory_ledger (instance_id, id);
+
+-- openvibe-sdk service outbox (inventory.item.*, inventory.definition.published).
+CREATE TABLE IF NOT EXISTS event_outbox (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ event_id text NOT NULL UNIQUE,
+ envelope jsonb NOT NULL,
+ traceparent text,
+ created_at bigint NOT NULL,
+ attempts integer NOT NULL DEFAULT 0,
+ next_attempt_at bigint NOT NULL DEFAULT 0,
+ sent_at bigint,
+ seq bigint,
+ rejected_at bigint,
+ last_error text
+);
+CREATE INDEX IF NOT EXISTS event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
+CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;
+
+-- openvibe-sdk/account-data receipts (ADR-033): a redelivered export or deletion changes nothing.
+CREATE TABLE IF NOT EXISTS account_data_events (
+ id TEXT PRIMARY KEY,
+ kind TEXT NOT NULL,
+ subject TEXT NOT NULL,
+ outcome JSONB,
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ sent_at TIMESTAMPTZ
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,1405 @@
@@ -0,0 +1,1405 @@
+{
+ "name": "openvibe-inventory",
+ "version": "0.2.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-inventory",
+ "version": "0.2.0",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.20.0",
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "helmet": "^8.1.0",
+ "iovalkey": "^0.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.121.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
+ "pg": "^8.23.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
+ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.8",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
+ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.16.0",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-parser": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.3",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
+ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.13",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.16.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "7.5.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
+ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/helmet": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
+ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/EvanHahn"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/iovalkey/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/openvibe-contracts": {
+ "version": "0.121.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.121.0",
+ "integrity": "sha512-z33smiDvUJ8uPsA4OFLI5CF/wHQV4wi0ijtNBKWQ2bhv8ExzwUPs1y48hPN0dB7KtRc1mi3iJrG7A4dufGOblg==",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1"
+ },
+ "bin": {
+ "openvibe-contracts-check": "bin/check-service.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/openvibe-sdk": {
+ "version": "0.37.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",
+ "integrity": "sha512-afojId2KzOL4ZZrpwIeIAYUaX24ZOC3Nt+bdd1HSXm7aislt8WQNS62i8/d8W3udLr6rmSG29SjmnaRHUGfd4Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "better-sqlite3": ">=11",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0"
+ },
+ "peerDependenciesMeta": {
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "better-sqlite3": {
+ "optional": true
+ },
+ "iovalkey": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/openvibe-shared": {
+ "version": "2.15.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
+ "integrity": "sha512-xApTyiUBVDQLTdnKEjjr/9uUU4+T5gofz9DqXv0MrLVINmW88uwbfoEht3kAlG9mp1792U6kIFEblJDHn2wyMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ },
+ "bin": {
+ "openvibe-build-assets": "scripts/build-assets.js"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/pg": {
+ "version": "8.23.1",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
+ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.1",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.1",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.1"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
+ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
+ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
+ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.16.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+{
+ "name": "openvibe-inventory",
+ "version": "0.2.0",
+ "private": true,
+ "description": "OpenVibe.Inventory — Your items, on every OpenVibe site.",
+ "main": "server/index.js",
+ "scripts": {
+ "start": "node server/index.js",
+ "dev": "NODE_ENV=development node server/index.js",
+ "test": "node test/run.js",
+ "test:pg": "INVENTORY_TEST_STORE=pg node test/run.js"
+ },
+ "keywords": [
+ "openvibe"
+ ],
+ "author": "OpenVibers",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.20.0",
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "helmet": "^8.1.0",
+ "iovalkey": "^0.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.121.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
+ "pg": "^8.23.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+/* OpenVibe.Inventory — theme-token driven (openvibe-shared presets). Every page works without JavaScript. */
+:root { color-scheme: dark; }
+* { box-sizing: border-box; }
+body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
+a { color: var(--accent-light, #60a5fa); }
+a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, summary:focus-visible { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }
+.skip { position: absolute; left: -9999px; }
+.skip:focus { left: 16px; top: 8px; background: var(--bg-card, #131c2e); padding: 8px 12px; z-index: 10; }
+.account-bar { max-width: 1080px; margin: 0 auto; padding: 6px 16px 0; text-align: right; font-size: 14px; color: var(--text-secondary, #96a7c2); }
+.page { max-width: 1080px; margin: 0 auto; padding: 16px 16px 64px; min-height: 60vh; }
+h1 { font-size: 1.8rem; line-height: 1.2; margin: 1rem 0 .6rem; overflow-wrap: anywhere; }
+h2 { font-size: 1.3rem; margin: 1.8rem 0 .5rem; }
+h3 { font-size: 1.08rem; margin: 1.2rem 0 .4rem; }
+.muted { color: var(--text-muted, #7386a3); }
+.small { font-size: .85rem; }
+.crumbs { font-size: .9rem; color: var(--text-muted, #7386a3); margin: 8px 0; overflow-wrap: anywhere; }
+code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .88em; }
+code { overflow-wrap: anywhere; }
+pre { background: var(--bg-secondary, #0f1729); border: 1px solid var(--border, #1f2d47); border-radius: 8px; padding: 10px 12px; overflow-x: auto; white-space: pre; }
+pre code { overflow-wrap: normal; }
+.table-wrap { overflow-x: auto; }
+table { border-collapse: collapse; width: 100%; font-size: .92rem; }
+th, td { text-align: left; vertical-align: top; padding: 7px 9px; border-bottom: 1px solid var(--border, #1f2d47); }
+th { color: var(--text-secondary, #96a7c2); font-weight: 600; }
+.badge { display: inline-block; font-size: .75rem; padding: 0 8px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); white-space: nowrap; line-height: 1.6; }
+.badge.ok { border-color: var(--success, #22c55e); color: var(--success, #22c55e); }
+.badge.info { border-color: var(--info, #38bdf8); color: var(--info, #38bdf8); }
+.badge.warn { border-color: var(--warning, #f59e0b); color: var(--warning, #f59e0b); }
+.badge.bad { border-color: var(--error, #ef4444); color: var(--error, #ef4444); }
+.notice { border-left: 3px solid var(--info, #38bdf8); padding: 8px 12px; margin: 12px 0; background: var(--bg-card, #131c2e); border-radius: 0 8px 8px 0; }
+.notice.ok { border-left-color: var(--success, #22c55e); }
+.notice.warn { border-left-color: var(--warning, #f59e0b); }
+.notice.bad { border-left-color: var(--error, #ef4444); }
+button { font: inherit; cursor: pointer; }
+form.inline { display: inline; }
+.prose { max-width: 820px; overflow-wrap: anywhere; }
+.prose table { margin: .6rem 0; }
+.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
+
+/* The home page's showcase sections (openvibe-shared/showcase.css) sit inside .page, which has its own gutter. */
+.page .sc-hero, .page .sc-sec, .page .sc-cta { padding-left: 0; padding-right: 0; }
+.page .sc-ic .ov-icon { --ovi-size: 28px !important; }
+button.sc-btn { background: transparent; }
+button.sc-btn.sc-primary { border: 0; }
+.lede { color: var(--text-secondary, #96a7c2); max-width: 72ch; }
+.stats { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }
+.stats li { border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 8px 12px; background: var(--bg-card, #131c2e); color: var(--text-secondary, #96a7c2); }
+.stats b { display: block; font-size: 1.15rem; color: var(--text-primary, #e6edf7); }
+
+/* ── Rarity: one hue per tier, shown as it is (ADR-054) ── */
+.r-common { --rar: #94a3b8; }
+.r-uncommon { --rar: #4ade80; }
+.r-rare { --rar: #38bdf8; }
+.r-epic { --rar: #c084fc; }
+.r-legendary { --rar: #fbbf24; }
+.inv-rarity { display: inline-block; font-size: .72rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--rar); border: 1px solid color-mix(in srgb, var(--rar) 55%, transparent); background: color-mix(in srgb, var(--rar) 12%, transparent); border-radius: 999px; padding: 0 8px; line-height: 1.7; white-space: nowrap; }
+
+/* ── The item tile: its emoji or image on a rarity glow ── */
+.inv-art { display: inline-grid; place-items: center; flex: none; border-radius: 14px; line-height: 1; background: radial-gradient(circle at 50% 35%, color-mix(in srgb, var(--rar, #94a3b8) 30%, transparent), color-mix(in srgb, var(--rar, #94a3b8) 6%, var(--bg-secondary, #0f1729)) 70%); border: 1px solid color-mix(in srgb, var(--rar, #94a3b8) 40%, var(--border, #1f2d47)); }
+.inv-art img { width: 100%; height: 100%; object-fit: contain; border-radius: inherit; }
+.inv-art-md { width: 56px; height: 56px; font-size: 28px; }
+.inv-art-lg { width: 64px; height: 64px; font-size: 32px; }
+.inv-art-xl { width: 112px; height: 112px; font-size: 58px; border-radius: 22px; }
+.r-legendary .inv-art, .inv-art.r-legendary { box-shadow: 0 0 0 1px color-mix(in srgb, var(--rar) 25%, transparent), 0 10px 30px -12px color-mix(in srgb, var(--rar) 70%, transparent); }
+
+/* ── Item cards ── */
+.inv-grid { list-style: none; padding: 0; margin: 12px 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); }
+.inv-card { display: flex; flex-direction: column; gap: 8px; border: 1px solid var(--border, #1f2d47); border-top: 3px solid color-mix(in srgb, var(--rar) 70%, transparent); border-radius: 12px; padding: 12px; background: var(--bg-card, #131c2e); min-width: 0; }
+.inv-card-link { display: flex; gap: 12px; align-items: center; color: inherit; text-decoration: none; min-width: 0; }
+.inv-card-link:hover .inv-card-name { text-decoration: underline; }
+.inv-card-body { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; min-width: 0; }
+.inv-card-name { font-weight: 650; overflow-wrap: anywhere; }
+.inv-card-desc { margin: 0; font-size: .88rem; color: var(--text-secondary, #96a7c2); }
+.inv-card-meta { margin: 0; font-size: .8rem; color: var(--text-muted, #7386a3); }
+.inv-equip { display: flex; align-items: center; gap: 8px; margin-top: auto; }
+.inv-equip .sc-btn { padding: 6px 14px; font-size: .88rem; }
+.inv-worn { margin: 0; font-size: .8rem; font-weight: 700; color: var(--success, #22c55e); }
+.inv-worn::before { content: "● "; }
+
+/* ── Kind tabs on /items ── */
+.inv-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 14px 0 6px; }
+.inv-tabs a { padding: 5px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-secondary, #96a7c2); text-decoration: none; font-size: .9rem; }
+.inv-tabs a[aria-current="page"] { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }
+.inv-kind h2 .small { font-weight: 400; }
+
+/* ── One item ── */
+.inv-detail { display: flex; gap: 20px; align-items: center; flex-wrap: wrap; margin: 6px 0 4px; }
+.inv-detail h1 { margin: 0 0 6px; }
+
+/* ── The home hero's panel: a few of the rarest items ── */
+.inv-hero-panel { padding: 20px; border-radius: 18px; border: 1px solid var(--border, #1f2d47); background: linear-gradient(160deg, color-mix(in srgb, #fbbf24 8%, var(--bg-card, #131c2e)), var(--bg-card, #131c2e) 65%); box-shadow: 0 18px 40px -24px rgba(0, 0, 0, .7); }
+.inv-hero-title { margin: 0 0 12px; font-weight: 750; font-size: 1rem; }
+.inv-hero-grid { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
+.inv-hero-grid a { display: flex; flex-direction: column; align-items: center; gap: 6px; color: inherit; text-decoration: none; text-align: center; }
+.inv-hero-grid a:hover .inv-hero-name { text-decoration: underline; }
+.inv-hero-name { font-size: .78rem; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; line-height: 1.25; }
+@media (max-width: 520px) { .inv-art-xl { width: 88px; height: 88px; font-size: 46px; } .inv-grid { grid-template-columns: 1fr; } }
+.inv-btn { font: inherit; font-size: .88rem; font-weight: 650; padding: 6px 16px; border-radius: 10px; border: 1px solid var(--border-light, #2c3d5c); background: transparent; color: var(--text-primary, #e6edf7); }
+.inv-btn:hover { border-color: var(--accent, #3b82f6); background: var(--bg-hover, #1c2a44); }
+.inv-btn.primary { border-color: transparent; color: var(--on-accent, #fff); background: linear-gradient(135deg, var(--accent, #3b82f6), var(--accent-dark, #1d4ed8)); }
+.inv-btn.primary:hover { filter: brightness(1.08); }
+.inv-equip .inv-worn { margin-right: auto; }
+.inv-card.is-worn { border-color: color-mix(in srgb, var(--success, #22c55e) 55%, var(--border, #1f2d47)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--success, #22c55e) 30%, transparent); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * ADR-054 §8, convert and verify: Live's cosmetics become instances and equipped slots here
+ * (server/inventory/import-live.js).
+ *
+ * node --env-file=/etc/openvibe/inventory.env scripts/import-live.js --live-env /etc/openvibe/live.env dry run
+ * node --env-file=/etc/openvibe/inventory.env scripts/import-live.js --live-env /etc/openvibe/live.env --apply convert
+ * node --env-file=/etc/openvibe/inventory.env scripts/import-live.js --live-env /etc/openvibe/live.env --verify compare
+ *
+ * Live's database is only read (SELECT). Its DATABASE_URL is taken from --live-env and never printed. The script prints
+ * counts and subjects only. --verify exits 1 when anyone differs.
+ */
+const fs = require('fs');
+const { createDb } = require('openvibe-sdk/db');
+const { createServiceOutbox } = require('openvibe-sdk/events');
+const configLib = require('../server/config');
+const { openDb, createStore } = require('../server/db');
+const { createInventory } = require('../server/inventory/store');
+const { seed } = require('../server/inventory/seed');
+const { importLive, verifyLive } = require('../server/inventory/import-live');
+
+const args = process.argv.slice(2);
+const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
+const flag = (name) => args.includes(`--${name}`);
+
+function readEnvValue(file, key) {
+ const line = fs.readFileSync(file, 'utf8').split('\n').find((l) => l.startsWith(`${key}=`));
+ if (!line) throw new Error(`${key} is not in ${file}`);
+ return line.slice(key.length + 1).trim().replace(/^(['"])(.*)\1$/, '$2');
+}
+
+(async () => {
+ const liveEnv = opt('live-env');
+ if (!liveEnv) throw new Error('--live-env <Live\'s env file> is required (its DATABASE_URL is read, never printed)');
+ const quiet = { log() {}, warn: (...a) => console.warn(...a), error: (...a) => console.error(...a) };
+ const live = createDb({ url: readEnvValue(liveEnv, 'DATABASE_URL'), service: 'inventory-import-live', max: 1, log: quiet });
+ const db = await openDb(configLib.load(), { log: quiet });
+ const s = createStore(db);
+ const inv = createInventory({ s, outbox: createServiceOutbox({ db, source: 'inventory', log: quiet }), log: quiet });
+ await seed(inv, { log: quiet });
+ try {
+ if (flag('verify')) {
+ const v = await verifyLive({ live, inv, db });
+ for (const d of v.differences) console.log(` ${d.subject}: missing ${d.missing.join(',') || '-'}; slots differing ${d.slots.join(',') || '-'}`);
+ if (v.unknown.length) console.log(` not in Live's catalog any more (not imported): ${v.unknown.join(', ')}`);
+ console.log(v.differences.length ? `verify: ${v.differences.length} of ${v.people} people differ` : `verify: all ${v.people} people match (${v.unlocks} unlocks, ${v.equippedSlots} slots)`);
+ process.exitCode = v.differences.length ? 1 : 0;
+ return;
+ }
+ const r = await importLive({ live, inv, db, apply: flag('apply'), log: console });
+ console.log(JSON.stringify({ apply: flag('apply'), ...r }));
+ } finally {
+ await live.close();
+ await db.close();
+ }
+})().catch((err) => { console.error(`import-live failed: ${err.message}`); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,166 @@
@@ -0,0 +1,166 @@
+'use strict';
+
+/**
+ * OpenVibe.Inventory — Express app factory; server/index.js listens, tests build their own instance with a temp database
+ * and a mock Network.
+ *
+ * /, /updates the pages (http/pages.js)
+ * /api/v1/* the API (http/api.js)
+ * /auth/* Network SSO with PKCE (auth/sso.js)
+ * /api/health, /api/ready, /release.json, /metrics (loopback only)
+ *
+ * The product fills this in: its routes in http/api.js, its pages in http/pages.js, its capabilities in
+ * http/principal.js and its budgets in http/caller-limits.js. The plumbing here (helmet, SSO, legal pages,
+ * static assets, the API mount, the 404 and the error handler) stays.
+ */
+const path = require('path');
+const express = require('express');
+const helmet = require('helmet');
+const cookieParser = require('cookie-parser');
+const rateLimit = require('express-rate-limit');
+const contracts = require('openvibe-contracts');
+const cache = require('openvibe-shared/cache-policy');
+
+const configLib = require('./config');
+const { openStore } = require('./db');
+const { createKeyStore } = require('./auth/keys');
+const { createSso } = require('./auth/sso');
+const { createPrincipal } = require('./http/principal');
+const { createApi } = require('./http/api');
+const { createPageRoutes } = require('./http/pages');
+const { createServiceReadiness } = require('./observability');
+const { createCallerLimits } = require('./http/caller-limits');
+const { createServiceOutbox } = require('openvibe-sdk/events');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
+const { createInventory } = require('./inventory/store');
+const { seed } = require('./inventory/seed');
+const accountDataLib = require('./inventory/account-data');
+const { assetVersion, send } = require('./render/layout');
+const { html } = require('./render/html');
+
+const PUBLIC_DIR = path.join(__dirname, '..', 'public');
+const VERSION = require('../package.json').version;
+
+/**
+ * opts: config, store, now (clock), fetchImpl (Network), log, limitsNow, callerLimits (false: count nobody,
+ * tests only), valkey, eventsFetch (the outbox relay's fetch, tests), accountSend (a stand-in for Network's internal
+ * routes, tests)
+ */
+async function createApp(opts = {}) {
+ const config = opts.config || configLib.load();
+ const log = opts.log || console;
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
+ const s = opts.store || await openStore(config, { now: opts.now, log });
+
+ const keys = createKeyStore({ config, fetchImpl, log });
+ const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
+ const principal = createPrincipal({ config, keys });
+ // The inventory (ADR-054) and its events, written in the same transaction as each change.
+ const outbox = createServiceOutbox({
+ db: s.db, source: 'inventory', eventsUrl: config.events.url, networkInternalUrl: config.networkInternalUrl,
+ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, intervalMs: config.events.intervalMs,
+ now: s.now, fetch: opts.eventsFetch || fetchImpl, log,
+ });
+ const inv = createInventory({ s, outbox, log });
+ await seed(inv, { log });
+ // Account export and deletion (ADR-033): the table map, and the sender to Network's internal routes with this
+ // service's own client-credentials token.
+ const accountData = accountDataLib.create({ db: s.db, log });
+ const accountSend = opts.accountSend || (config.oauth.clientSecret
+ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
+ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Inventory cannot answer account events'); });
+ const ctx = { config, s, keys, sso, principal, log, outbox, inv, accountData };
+
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', config.trustProxy);
+ const release = require('openvibe-shared/release').createRelease({ service: 'inventory', root: path.join(__dirname, '..') });
+ require('./render/layout').setRelease(release.release);
+ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'inventory', release: release.release });
+ app.locals.metrics = metrics.registry;
+ app.locals.ctx = ctx;
+ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);
+ ctx.valkey = valkey;
+ ctx.limits = createCallerLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: opts.callerLimits !== false, valkey });
+
+ app.use(contracts.http.middleware());
+ app.use(helmet({
+ contentSecurityPolicy: {
+ directives: {
+ defaultSrc: ["'self'"],
+ // The OpenVibe Frame (theme-loader, navbar, footer) comes from the Network; the inline init is ours.
+ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
+ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
+ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
+ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
+ connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
+ frameSrc: ["'self'", 'https://openvibe.network'],
+ frameAncestors: ["'none'"],
+ objectSrc: ["'none'"],
+ baseUri: ["'self'"],
+ formAction: ["'self'", 'https://openvibe.network'],
+ },
+ },
+ frameguard: { action: 'deny' },
+ crossOriginEmbedderPolicy: false,
+ crossOriginResourcePolicy: { policy: 'same-site' },
+ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
+ }));
+ app.use(cookieParser());
+
+ // ── OpenVibe.Events → Inventory (loopback only: nginx answers 404 for /internal/) ──
+ // network.account.export_requested and network.account.deleted, answered by openvibe-sdk/account-data's consumer,
+ // which reads the raw body itself (the v2 signature covers it) and refuses a request that came through a proxy.
+ app.post('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));
+
+ // ── Machine endpoints ───────────────────────────────────
+ app.get('/api/health', (_req, res, next) => outbox.status().then((events) => res.json({ status: 'ok', service: 'openvibe-inventory', version: VERSION, events }), next));
+ release.mount(app, { registry: metrics.registry });
+ const readiness = createServiceReadiness({ s, config, release: release.release, valkey });
+ app.get('/api/ready', readiness.handler);
+
+ // ── Who is asking (verified offline; refreshed when expired) ──
+ app.use(sso.middleware());
+
+ // ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
+ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
+ app.use('/auth', sso.routes());
+ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'inventory', service: 'inventory', host: 'inventory.openvibe.network', name: 'OpenVibe.Inventory', profile: 'ugc' })); }
+
+ // ── Static assets (content-hashed ?v= → immutable) ──────
+ app.use('/shared', require('openvibe-shared/serve').handler());
+ app.use(express.static(PUBLIC_DIR, {
+ index: false, redirect: false,
+ setHeaders(res, filePath) {
+ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
+ const v = res.req && res.req.query && res.req.query.v;
+ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));
+ },
+ }));
+
+ // ── API ─────────────────────────────────────────────────
+ app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.INVENTORY_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
+ app.use('/api/v1', createApi(ctx));
+ app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
+
+ // ── Pages ───────────────────────────────────────────────
+ app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.INVENTORY_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
+ app.use(createPageRoutes(ctx));
+ app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/">the home page</a> or <a href="/updates">the update log</a>.</p>` }));
+
+ // eslint-disable-next-line no-unused-vars
+ app.use((err, req, res, _next) => {
+ // Never log request bodies.
+ log.error('[OpenVibe.Inventory]', err && err.message ? err.message.slice(0, 300) : err);
+ if (res.headersSent) return;
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (err && err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });
+ if (err && err.type === 'entity.too.large') return req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov }) : res.status(413).type('text/plain').send('That request was too large.');
+ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
+ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
+ });
+
+ return { app, ctx };
+}
+
+module.exports = { createApp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+'use strict';
+
+/**
+ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured
+ * internal URL and kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline
+ * against it: the person's session token (sign-in).
+ *
+ * The SDK keeps one client per URL: the last good keys through outages, exponential backoff, a
+ * rotation honoured at once, unknown-kid floods throttled, the refresh timer unref'd. This module
+ * is a thin shim so the rest of OpenVibe.Inventory (the readiness check, the SSO middleware) can keep talking to
+ * `keys.get()` / `keys.ensure()` / `keys.loaded()`,
+ * while verification goes through the SDK's verifyUserToken / verifyAppToken with `jwks: <url>`.
+ *
+ * Nothing the SDK says about a failed key fetch leaves this module: its message names the internal
+ * JWKS URL and the fetch error, so token.no_key answers 'signing key not loaded yet' (and an error
+ * without a code 'token does not verify'). Reasons about the token itself (audience, expiry,
+ * signature) are kept. Every rejection is logged.
+ */
+const sdk = require('openvibe-sdk/auth');
+
+/** Public reasons. The SDK's own text (the JWKS URL, the fetch error) never goes over the wire. */
+const KEY_UNAVAILABLE = 'signing key not loaded yet';
+const DOES_NOT_VERIFY = 'token does not verify';
+
+/**
+ * What a caller may be told. Only token.no_key carries the SDK's fetch text (the internal JWKS URL and the connect
+ * error); every other code's message is about the token itself ('not for openvibe.media', 'expired'), which a
+ * developer in the playground needs, so it is kept.
+ */
+function publicReason(err) {
+ const code = err && err.code;
+ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;
+ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;
+}
+
+/** Resolve the JWKS URL for the configured Network (internal first, then public). */
+function jwksUrl(config) {
+ return (config.networkInternalUrl || config.networkUrl) + '/api/.well-known/jwks';
+}
+
+/**
+ * `keys` is what app.js hands the rest of the service: a facade over the SDK's jwksClient. A synchronous caller
+ * wants a PEM, so we mirror the cached first key as PEM — refreshed every time the SDK gives us keys (ensure(),
+ * verifyUser(), verifyApp()).
+ */
+function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {
+ const url = jwksUrl(config);
+ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });
+ let mirror = null; // { pem, count } — the first cached key as PEM
+
+ async function refreshMirror() {
+ try {
+ const ks = await client.keys();
+ const first = ks && ks[0];
+ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;
+ } catch (err) { log.warn(`[OpenVibe.Inventory] JWKS mirror refresh failed: ${err && err.message || err}`); mirror = null; }
+ }
+ /** First fetch (or wait out the backoff window). Refreshes the mirror. */
+ async function ensure() { await refreshMirror(); }
+ /** Cached public key as PEM — for openvibe-contracts' verifyServiceToken (synchronous). */
+ function get() { return mirror ? mirror.pem : null; }
+ function loaded() { return Boolean(mirror); }
+
+ /** The SDK's `now` is a NUMBER of milliseconds (it does Math.floor(now / 1000)); a function disables every expiry check. */
+ const asMs = (v) => (typeof v === 'function' ? v() : (typeof v === 'number' ? v : Date.now()));
+
+ // Verify a user (session) token via the SDK. Returns { ok, claims } | { ok: false, reason, code, expired }.
+ // `reason` is one of the two fixed public strings; the SDK's text is logged, never returned.
+ async function verifyUser(token, { issuer, audience, now = Date.now() } = {}) {
+ try {
+ const claims = await sdk.verifyUserToken(token, { jwks: url, issuer, audience, now: asMs(now), log });
+ await refreshMirror();
+ return { ok: true, claims };
+ } catch (err) {
+ await refreshMirror();
+ const code = err && err.code;
+ log.warn(`[OpenVibe.Inventory] user token rejected (${code || 'error'}):`, (err && err.message) || err);
+ return { ok: false, reason: publicReason(err), code, expired: code === 'token.expired' };
+ }
+ }
+ // Verify an app token. Same shape and same fixed reasons. The playground reads v.reason for its detail.
+ async function verifyApp(token, { issuer, audience, acceptSandbox = true, now = Date.now() } = {}) {
+ try {
+ const claims = await sdk.verifyAppToken(token, { jwks: url, issuer, audience, acceptSandbox, now: asMs(now), log });
+ await refreshMirror();
+ return { ok: true, claims };
+ } catch (err) {
+ await refreshMirror();
+ const code = err && err.code;
+ log.warn(`[OpenVibe.Inventory] app token rejected (${code || 'error'}):`, (err && err.message) || err);
+ return { ok: false, reason: publicReason(err), code };
+ }
+ }
+
+ /**
+ * The PEM for the key a token's header names (a rotation publishes two keys: the first one is not always it), for
+ * openvibe-contracts' synchronous guard. The SDK refetches for an unknown kid (at most every 30 s). Null when no
+ * key is loaded; a token that names no kid gets the first key.
+ */
+ async function pemForToken(token) {
+ let kid = null;
+ try { kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { /* the guard refuses it */ }
+ try {
+ const ks = await client.keysForKid(kid);
+ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];
+ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;
+ } catch { return null; }
+ }
+
+ return { ensure, get, loaded, status: client.status, verifyUser, verifyApp, pemForToken, client };
+}
+
+module.exports = { createKeyStore, jwksUrl };
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,258 @@
@@ -0,0 +1,258 @@
+'use strict';
+
+/**
+ * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `inventory`.
+ *
+ * GET /auth/login → Network /oauth/authorize?…&code_challenge=…&code_challenge_method=S256
+ * (?silent=1 adds prompt=none; ?next= a same-site path)
+ * GET /auth/callback → state check, server-side code exchange (client secret + code_verifier)
+ * GET /auth/logout → clear cookies, revoke the refresh token (best effort)
+ * GET /auth/me → { user } for the shared navbar (it cannot read the httpOnly cookie); { user: null } for a guest
+ *
+ * The person's Network access token lives in an httpOnly cookie (inventory_at) no script can read: with it, a script
+ * could act as them. The refresh token (inventory_rt) is httpOnly too. Neither is ever stored server-side, rendered
+ * or logged.
+ */
+const crypto = require('crypto');
+const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
+
+const ACCESS_COOKIE = 'inventory_at';
+const REFRESH_COOKIE = 'inventory_rt';
+const HINT_COOKIE = 'ov_sso_hint';
+const FLOW_COOKIE = 'inventory_oauth';
+
+function pkcePair() {
+ const verifier = crypto.randomBytes(32).toString('base64url'); // 43 characters
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
+ return { verifier, challenge };
+}
+
+/** Same-site relative paths only (never protocol-relative, never another origin). */
+function sanitizeNext(next) {
+ // Browsers drop tab and newline characters from a URL and read a backslash as "/": "/<TAB>/evil.com" would
+ // leave the site. A next with any control character or backslash goes home.
+ if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
+ if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';
+ return next;
+}
+
+function authorizeUrl(config, { state, challenge, silent = false }) {
+ const q = new URLSearchParams({
+ response_type: 'code',
+ client_id: config.oauth.clientId,
+ redirect_uri: config.oauth.redirectUri,
+ scope: config.oauth.scope,
+ state,
+ code_challenge: challenge,
+ code_challenge_method: 'S256',
+ });
+ if (silent) q.set('prompt', 'none');
+ return `${config.networkUrl}/oauth/authorize?${q.toString()}`;
+}
+
+function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
+ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });
+
+ function setSession(res, data) {
+ res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });
+ if (data.refresh_token) res.cookie(REFRESH_COOKIE, data.refresh_token, { ...cookieBase(), maxAge: 30 * 24 * 60 * 60 * 1000 });
+ res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
+ }
+ function clearSession(res) {
+ res.clearCookie(ACCESS_COOKIE, cookieBase());
+ res.clearCookie(REFRESH_COOKIE, cookieBase());
+ }
+
+ /** POST the Network token endpoint (internal URL). Throws { status, error } on refusal. */
+ async function tokenGrant(fields) {
+ const body = new URLSearchParams({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...fields });
+ let res;
+ try {
+ res = await fetchImpl(`${config.networkInternalUrl}/oauth/token`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
+ body, signal: AbortSignal.timeout(10_000),
+ });
+ } catch (err) {
+ throw Object.assign(new Error('OpenVibe.Network did not answer'), { status: 502, error: err && err.name === 'TimeoutError' ? 'timeout' : 'unreachable' });
+ }
+ const data = await res.json().catch(() => null);
+ if (!res.ok || !data || !data.access_token) {
+ throw Object.assign(new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`),
+ { status: res.status, error: (data && data.error) || 'invalid_grant' });
+ }
+ return data;
+ }
+
+ /**
+ * A OpenVibe.Inventory session is a Network SESSION token: audience openvibe.network and a person. The Network
+ * signs FedCM assertions (audience = any owned-zone origin that asked), app/service tokens and
+ * internal tokens with the same key and issuer; none of them may become a session here.
+ */
+ async function verifySession(token) {
+ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });
+ const c = v.claims;
+ if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };
+ return v;
+ }
+
+ function viewerFromClaims(claims, token) {
+ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;
+ const role = typeof claims.role === 'string' ? claims.role : 'user';
+ return {
+ kind: 'user',
+ subject,
+ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
+ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (typeof claims.username === 'string' ? claims.username.slice(0, 64) : 'you'),
+ role,
+ // The person's Network access token: used for server-side Network calls, never rendered.
+ token,
+ };
+ }
+
+ /** One refresh per request, however many Network calls in it see a 401 (refresh tokens rotate). */
+ async function refresh(req, res) {
+ if (!req.sessionRefresh) req.sessionRefresh = await doRefresh(req, res);
+ return req.sessionRefresh;
+ }
+
+ async function doRefresh(req, res) {
+ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
+ if (!rt) return null;
+ try {
+ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });
+ setSession(res, data);
+ const v = await verifySession(data.access_token);
+ return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;
+ } catch (err) {
+ if (err.status && err.status < 500) clearSession(res);
+ return null;
+ }
+ }
+
+ /** req.viewer for every page: the signed-in person, refreshed when their token expired. */
+ function middleware() {
+ return async (req, res, next) => {
+ req.viewer = { kind: 'anonymous' };
+ const token = req.cookies && req.cookies[ACCESS_COOKIE];
+ if (!token) return next();
+ await keys.ensure();
+ const v = await verifySession(token);
+ if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }
+ if (v.expired) {
+ const fresh = await refresh(req, res);
+ if (fresh) { req.viewer = fresh; return next(); }
+ }
+ if (keys.loaded()) clearSession(res);
+ next();
+ };
+ }
+
+ /**
+ * Run fn(token) as the viewer; when the Network answers 401 (expired or revoked session), refresh
+ * once and run it again. Other failures are returned to the caller unchanged.
+ */
+ async function asViewer(req, res, fn) {
+ try {
+ return await fn(req.viewer.token);
+ } catch (err) {
+ if (!err || err.status !== 401) throw err;
+ const fresh = await refresh(req, res);
+ if (!fresh) throw err;
+ req.viewer = fresh;
+ return fn(fresh.token);
+ }
+ }
+
+ function routes() {
+ const r = express.Router();
+ const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });
+
+ r.get('/login', (req, res) => {
+ const silent = Boolean(req.query.silent) && req.query.silent !== '0';
+ const state = crypto.randomBytes(16).toString('hex');
+ const { verifier, challenge } = pkcePair();
+ res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next), silent }), flowOpts());
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ res.redirect(authorizeUrl(config, { state, challenge, silent }));
+ });
+
+ r.get('/callback', async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ let flow = null;
+ try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }
+ res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });
+ const next = flow ? sanitizeNext(flow.next) : '/';
+ const { code, state, error } = req.query;
+ if (error) {
+ if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);
+ return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);
+ }
+ if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'
+ || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {
+ return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');
+ }
+ if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');
+ try {
+ const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: config.oauth.redirectUri, code_verifier: flow.verifier });
+ setSession(res, data);
+ return res.redirect(next);
+ } catch (err) {
+ log.warn('[OpenVibe.Inventory] sign-in exchange failed:', err.status || '', err.error || '');
+ return res.status(err.status && err.status < 500 ? 400 : 502).type('text/plain')
+ .send(err.status && err.status < 500 ? 'The Network refused the sign-in code. Please sign in again.' : 'Sign-in failed: OpenVibe.Network did not answer. Please try again.');
+ }
+ });
+
+ // Sign-out ends the session, so another site must not be able to trigger it (a cross-site
+ // <img src=/auth/logout> or link). A same-origin navigation or form, or the address bar
+ // (Sec-Fetch-Site none), signs out directly; anything else gets a confirm button (POST).
+ const sameOriginRequest = (req) => {
+ const site = String(req.get('sec-fetch-site') || '');
+ if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');
+ const origin = req.get('origin');
+ if (origin) return origin === `${req.protocol}://${req.get('host')}`;
+ return req.method === 'GET'; // no fetch metadata and no Origin: an old browser navigating
+ };
+ r.get('/logout', (req, res, next) => {
+ if (sameOriginRequest(req)) return next();
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next))}` : '';
+ res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`
+ + `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`
+ + `<p>Sign out of OpenVibe.Inventory?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);
+ });
+ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));
+ r.all('/logout', async (req, res) => {
+ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
+ if (rt) {
+ fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: rt }),
+ signal: AbortSignal.timeout(3000),
+ }).catch(() => {});
+ }
+ clearSession(res);
+ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
+ res.redirect(303, sanitizeNext(req.query.next));
+ });
+
+ r.get('/me', (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ // No session at all (a guest: no access or refresh cookie) is signed out, not an error: the shared
+ // navbar asks this on every page view, and a 401 logged a console error on each (browser check,
+ // OpenVibe.Host). A session cookie that is present but invalid or expired still answers 401.
+ const c = req.cookies || {};
+ if (!c[ACCESS_COOKIE] && !c[REFRESH_COOKIE] && !req.get('authorization')) return res.json({ user: null });
+ const v = req.viewer;
+ if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });
+ res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role } });
+ });
+ return r;
+ }
+
+ return { middleware, routes, asViewer, tokenGrant, pkcePair };
+}
+
+module.exports = { createSso, pkcePair, sanitizeNext, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+
+/**
+ * OpenVibe.Inventory configuration. Every value comes from the environment (production: /etc/openvibe/inventory.env, see
+ * .env.example). Only environment variable NAMES appear in code and docs; secrets are never logged.
+ *
+ * load(env) is pure so tests can build a config without touching process.env.
+ */
+require('dotenv').config();
+const trim = (s) => String(s || '').replace(/\/+$/, '');
+const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
+
+function load(env = process.env) {
+ const nodeEnv = env.NODE_ENV || 'development';
+ const isProduction = nodeEnv === 'production';
+ const port = int(env.PORT, 5030);
+ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://inventory.openvibe.network' : `http://localhost:${port}`));
+ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
+
+ return {
+ service: 'inventory',
+ port,
+ host: env.HOST || '127.0.0.1',
+ nodeEnv,
+ isProduction,
+ baseUrl,
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
+ // Per-caller limits (server/http/caller-limits.js): the requests one caller (an app, a person, else an
+ // address) may make per minute and per hour. The product's own routes add tighter budgets there.
+ limits: {
+ minute: Math.max(1, int(env.INVENTORY_LIMITS_MINUTE, 120)),
+ hour: Math.max(1, int(env.INVENTORY_LIMITS_HOUR, 3000)),
+ },
+
+ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role). In
+ // development without DATABASE_URL an embedded PGlite database in data/pglite is used (INVENTORY_PGLITE_DIR
+ // overrides the directory).
+ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.INVENTORY_PGLITE_DIR || '' },
+ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:inventory:' },
+
+ // OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
+ networkUrl,
+ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
+ networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
+ // The audience this service's app, agent and service tokens carry.
+ audience: env.INVENTORY_AUDIENCE || 'inventory.openvibe.network',
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'inventory',
+ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
+ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
+ scope: 'profile',
+ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
+ },
+ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
+
+ // OpenVibe.Events: the outbox relay publishes inventory.item.* and inventory.definition.published when EVENTS_URL
+ // and the client secret are set (otherwise events wait in event_outbox). INVENTORY_EVENTS_SECRET signs the
+ // deliveries to POST /internal/events (account export and deletion, ADR-033; comma-separated for rotation, 32+
+ // characters each); unset, the route answers 503 and no subscription is created at boot.
+ events: {
+ url: trim(env.EVENTS_URL || ''),
+ intervalMs: Math.max(50, int(env.EVENTS_RELAY_INTERVAL_MS, 2000)),
+ secrets: String(env.INVENTORY_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
+ },
+ };
+}
+
+module.exports = { load };
new file mode 100644
new file mode 100644
@@ -0,0 +1,211 @@
@@ -0,0 +1,211 @@
+{
+ "_about": "The kinds OpenVibe.Inventory registers at boot (ADR-054 §8). Live's cosmetics are the first.",
+ "kinds": [
+ {
+ "id": "live.name_effect",
+ "name": "Name effect",
+ "description": "How your name looks in chat, on overlays and on your profile.",
+ "slots": [
+ "name_effect"
+ ],
+ "surfaces": [
+ {
+ "surface": "chat",
+ "renderer": "live.name_effect.css@1"
+ },
+ {
+ "surface": "overlay",
+ "renderer": "live.name_effect.css@1"
+ },
+ {
+ "surface": "profile",
+ "renderer": "live.name_effect.css@1"
+ }
+ ],
+ "attribute_schema": {
+ "type": "object",
+ "required": [
+ "tier"
+ ],
+ "properties": {
+ "tier": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 6
+ },
+ "css_class": {
+ "type": "string",
+ "pattern": "^[a-z0-9-]{1,40}$"
+ }
+ }
+ },
+ "stackable": false,
+ "issuer": "service:live"
+ },
+ {
+ "id": "live.particle",
+ "name": "Particle effect",
+ "description": "Particles that drift around your name and messages.",
+ "slots": [
+ "particle"
+ ],
+ "surfaces": [
+ {
+ "surface": "chat",
+ "renderer": "live.particle.css@1"
+ },
+ {
+ "surface": "overlay",
+ "renderer": "live.particle.css@1"
+ },
+ {
+ "surface": "profile",
+ "renderer": "live.particle.css@1"
+ }
+ ],
+ "attribute_schema": {
+ "type": "object",
+ "required": [
+ "tier"
+ ],
+ "properties": {
+ "tier": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 6
+ },
+ "css_class": {
+ "type": "string",
+ "pattern": "^[a-z0-9-]{1,40}$"
+ },
+ "chars": {
+ "type": "string",
+ "maxLength": 16
+ }
+ }
+ },
+ "stackable": false,
+ "issuer": "service:live"
+ },
+ {
+ "id": "live.hat",
+ "name": "Hat",
+ "description": "A hat on your avatar and next to your name.",
+ "slots": [
+ "hat"
+ ],
+ "surfaces": [
+ {
+ "surface": "chat",
+ "renderer": "live.hat.glyph@1"
+ },
+ {
+ "surface": "overlay",
+ "renderer": "live.hat.glyph@1"
+ },
+ {
+ "surface": "profile",
+ "renderer": "live.hat.glyph@1"
+ }
+ ],
+ "attribute_schema": {
+ "type": "object",
+ "required": [
+ "tier"
+ ],
+ "properties": {
+ "tier": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 6
+ },
+ "hat_char": {
+ "type": "string",
+ "maxLength": 8
+ },
+ "animated": {
+ "enum": [
+ "float",
+ "pulse",
+ "warp"
+ ]
+ }
+ }
+ },
+ "stackable": false,
+ "issuer": "service:live"
+ },
+ {
+ "id": "live.voice",
+ "name": "Voice",
+ "description": "The voice your text-to-speech messages are read in.",
+ "slots": [
+ "voice"
+ ],
+ "surfaces": [
+ {
+ "surface": "chat",
+ "renderer": "live.voice.speech@1"
+ },
+ {
+ "surface": "overlay",
+ "renderer": "live.voice.speech@1"
+ }
+ ],
+ "attribute_schema": {
+ "type": "object",
+ "required": [
+ "tier"
+ ],
+ "properties": {
+ "tier": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 6
+ },
+ "pitch": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 2
+ },
+ "rate": {
+ "type": "number",
+ "minimum": 0.1,
+ "maximum": 3
+ }
+ }
+ },
+ "stackable": false,
+ "issuer": "service:live"
+ },
+ {
+ "id": "live.chat_tag",
+ "name": "Chat tag",
+ "description": "A short tag shown before your name in chat.",
+ "slots": [
+ "chat_tag"
+ ],
+ "surfaces": [
+ {
+ "surface": "chat",
+ "renderer": "live.chat_tag.text@1"
+ },
+ {
+ "surface": "profile",
+ "renderer": "live.chat_tag.text@1"
+ }
+ ],
+ "attribute_schema": {
+ "type": "object",
+ "properties": {
+ "text": {
+ "type": "string",
+ "maxLength": 24
+ }
+ }
+ },
+ "stackable": false,
+ "issuer": "service:live"
+ }
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,1056 @@
@@ -0,0 +1,1056 @@
+{
+ "_about": "Live's cosmetics catalog (OpenVibe.Live server/monetization/cosmetics.js COSMETICS, 2026-10-09) as definitions issued by service:live; alias is Live's item id. Tier maps to rarity: 0 and 1 common, 2 uncommon, 3 rare, 4 epic, 5 and 6 legendary.",
+ "definitions": [
+ {
+ "alias": "fx_rainbow",
+ "kind": "live.name_effect",
+ "name": "Rainbow Name",
+ "description": "Rainbow cycling colors",
+ "art": {
+ "emoji": "🌈",
+ "token": "name-fx-rainbow"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "css_class": "name-fx-rainbow"
+ }
+ },
+ {
+ "alias": "fx_fire",
+ "kind": "live.name_effect",
+ "name": "Fire Name",
+ "description": "Blazing flames",
+ "art": {
+ "emoji": "🔥",
+ "token": "name-fx-fire"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "css_class": "name-fx-fire"
+ }
+ },
+ {
+ "alias": "fx_ice",
+ "kind": "live.name_effect",
+ "name": "Ice Name",
+ "description": "Frosty glow",
+ "art": {
+ "emoji": "❄️",
+ "token": "name-fx-ice"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "css_class": "name-fx-ice"
+ }
+ },
+ {
+ "alias": "fx_golden",
+ "kind": "live.name_effect",
+ "name": "Golden Name",
+ "description": "Golden shine",
+ "art": {
+ "emoji": "👑",
+ "token": "name-fx-golden"
+ },
+ "rarity": "uncommon",
+ "attributes": {
+ "tier": 2,
+ "css_class": "name-fx-golden"
+ }
+ },
+ {
+ "alias": "fx_neon",
+ "kind": "live.name_effect",
+ "name": "Neon Name",
+ "description": "Neon pulse",
+ "art": {
+ "emoji": "💡",
+ "token": "name-fx-neon"
+ },
+ "rarity": "uncommon",
+ "attributes": {
+ "tier": 2,
+ "css_class": "name-fx-neon"
+ }
+ },
+ {
+ "alias": "fx_galaxy",
+ "kind": "live.name_effect",
+ "name": "Galaxy Name",
+ "description": "Cosmic swirl",
+ "art": {
+ "emoji": "🌌",
+ "token": "name-fx-galaxy"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "css_class": "name-fx-galaxy"
+ }
+ },
+ {
+ "alias": "fx_void",
+ "kind": "live.name_effect",
+ "name": "Void Name",
+ "description": "Warps spacetime",
+ "art": {
+ "emoji": "🕳️",
+ "token": "name-fx-void"
+ },
+ "rarity": "epic",
+ "attributes": {
+ "tier": 4,
+ "css_class": "name-fx-void"
+ }
+ },
+ {
+ "alias": "fx_toxic",
+ "kind": "live.name_effect",
+ "name": "Toxic Name",
+ "description": "Toxic drip",
+ "art": {
+ "emoji": "☠️",
+ "token": "name-fx-toxic"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "css_class": "name-fx-toxic"
+ }
+ },
+ {
+ "alias": "fx_blood",
+ "kind": "live.name_effect",
+ "name": "Blood Name",
+ "description": "Dripping blood",
+ "art": {
+ "emoji": "🩸",
+ "token": "name-fx-blood"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "css_class": "name-fx-blood"
+ }
+ },
+ {
+ "alias": "fx_shadow",
+ "kind": "live.name_effect",
+ "name": "Shadow Name",
+ "description": "Dark shadow",
+ "art": {
+ "emoji": "🌑",
+ "token": "name-fx-shadow"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "css_class": "name-fx-shadow"
+ }
+ },
+ {
+ "alias": "fx_glitch",
+ "kind": "live.name_effect",
+ "name": "Glitch Name",
+ "description": "Digital glitch",
+ "art": {
+ "emoji": "📟",
+ "token": "name-fx-glitch"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "css_class": "name-fx-glitch"
+ }
+ },
+ {
+ "alias": "fx_hologram",
+ "kind": "live.name_effect",
+ "name": "Hologram Name",
+ "description": "Holographic shimmer",
+ "art": {
+ "emoji": "🔮",
+ "token": "name-fx-hologram"
+ },
+ "rarity": "epic",
+ "attributes": {
+ "tier": 4,
+ "css_class": "name-fx-hologram"
+ }
+ },
+ {
+ "alias": "fx_divine",
+ "kind": "live.name_effect",
+ "name": "Divine Name",
+ "description": "Divine radiance",
+ "art": {
+ "emoji": "✝️",
+ "token": "name-fx-divine"
+ },
+ "rarity": "legendary",
+ "attributes": {
+ "tier": 5,
+ "css_class": "name-fx-divine"
+ }
+ },
+ {
+ "alias": "px_sparkle",
+ "kind": "live.particle",
+ "name": "Sparkle",
+ "description": "Sparkle particles",
+ "art": {
+ "emoji": "✨",
+ "token": "px-sparkle"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "css_class": "px-sparkle",
+ "chars": "✦✧⋆"
+ }
+ },
+ {
+ "alias": "px_hearts",
+ "kind": "live.particle",
+ "name": "Hearts",
+ "description": "Heart particles",
+ "art": {
+ "emoji": "💖",
+ "token": "px-hearts"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "css_class": "px-hearts",
+ "chars": "♥♡❤"
+ }
+ },
+ {
+ "alias": "px_flames",
+ "kind": "live.particle",
+ "name": "Flames",
+ "description": "Flame embers",
+ "art": {
+ "emoji": "🔥",
+ "token": "px-flames"
+ },
+ "rarity": "uncommon",
+ "attributes": {
+ "tier": 2,
+ "css_class": "px-flames",
+ "chars": "🔥🔸⚡"
+ }
+ },
+ {
+ "alias": "px_stars",
+ "kind": "live.particle",
+ "name": "Stars",
+ "description": "Orbiting stars",
+ "art": {
+ "emoji": "⭐",
+ "token": "px-stars"
+ },
+ "rarity": "uncommon",
+ "attributes": {
+ "tier": 2,
+ "css_class": "px-stars",
+ "chars": "★☆✩"
+ }
+ },
+ {
+ "alias": "px_void",
+ "kind": "live.particle",
+ "name": "Void",
+ "description": "Dark matter",
+ "art": {
+ "emoji": "🕳️",
+ "token": "px-void"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "css_class": "px-void",
+ "chars": "◉◎⊙"
+ }
+ },
+ {
+ "alias": "hat_basic_cap",
+ "kind": "live.hat",
+ "name": "Basic Cap",
+ "description": "Simple cap",
+ "art": {
+ "emoji": "🧢"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "hat_char": "🧢"
+ }
+ },
+ {
+ "alias": "hat_cowboy",
+ "kind": "live.hat",
+ "name": "Cowboy Hat",
+ "description": "Yeehaw",
+ "art": {
+ "emoji": "🤠"
+ },
+ "rarity": "uncommon",
+ "attributes": {
+ "tier": 2,
+ "hat_char": "🤠"
+ }
+ },
+ {
+ "alias": "hat_wizard",
+ "kind": "live.hat",
+ "name": "Wizard Hat",
+ "description": "Magical",
+ "art": {
+ "emoji": "🧙"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "hat_char": "🧙",
+ "animated": "float"
+ }
+ },
+ {
+ "alias": "hat_crown",
+ "kind": "live.hat",
+ "name": "Royal Crown",
+ "description": "Royalty",
+ "art": {
+ "emoji": "👑"
+ },
+ "rarity": "epic",
+ "attributes": {
+ "tier": 4,
+ "hat_char": "👑",
+ "animated": "pulse"
+ }
+ },
+ {
+ "alias": "hat_halo",
+ "kind": "live.hat",
+ "name": "Halo",
+ "description": "Angelic",
+ "art": {
+ "emoji": "😇"
+ },
+ "rarity": "legendary",
+ "attributes": {
+ "tier": 5,
+ "hat_char": "😇",
+ "animated": "float"
+ }
+ },
+ {
+ "alias": "hat_void_crown",
+ "kind": "live.hat",
+ "name": "Void Crown",
+ "description": "From the dungeon depths",
+ "art": {
+ "emoji": "🕳️"
+ },
+ "rarity": "legendary",
+ "attributes": {
+ "tier": 6,
+ "hat_char": "🕳️",
+ "animated": "warp"
+ }
+ },
+ {
+ "alias": "voice_default",
+ "kind": "live.voice",
+ "name": "Default Voice",
+ "description": "Standard TTS",
+ "art": {
+ "emoji": "🔊"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 0
+ }
+ },
+ {
+ "alias": "voice_deep",
+ "kind": "live.voice",
+ "name": "Deep Voice",
+ "description": "Low and rumbly",
+ "art": {
+ "emoji": "🎵"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "pitch": 0.6,
+ "rate": 0.9
+ }
+ },
+ {
+ "alias": "voice_chipmunk",
+ "kind": "live.voice",
+ "name": "Chipmunk Voice",
+ "description": "Squeaky and fast",
+ "art": {
+ "emoji": "🐿️"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "pitch": 1.8,
+ "rate": 1.3
+ }
+ },
+ {
+ "alias": "voice_robot",
+ "kind": "live.voice",
+ "name": "Robot Voice",
+ "description": "Monotone machine",
+ "art": {
+ "emoji": "🤖"
+ },
+ "rarity": "uncommon",
+ "attributes": {
+ "tier": 2,
+ "pitch": 0.8,
+ "rate": 1
+ }
+ },
+ {
+ "alias": "voice_whisper",
+ "kind": "live.voice",
+ "name": "Whisper Voice",
+ "description": "Quiet and eerie",
+ "art": {
+ "emoji": "🤫"
+ },
+ "rarity": "uncommon",
+ "attributes": {
+ "tier": 2,
+ "pitch": 1.1,
+ "rate": 0.7
+ }
+ },
+ {
+ "alias": "voice_demon",
+ "kind": "live.voice",
+ "name": "Demon Voice",
+ "description": "From the underworld",
+ "art": {
+ "emoji": "😈"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "pitch": 0.3,
+ "rate": 0.6
+ }
+ },
+ {
+ "alias": "gary",
+ "kind": "live.voice",
+ "name": "Gary",
+ "description": "Standard voice",
+ "art": {
+ "emoji": "🔊"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "pitch": 1,
+ "rate": 1
+ }
+ },
+ {
+ "alias": "brenda",
+ "kind": "live.voice",
+ "name": "Brenda",
+ "description": "Friendly female",
+ "art": {
+ "emoji": "👩"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "pitch": 1.2,
+ "rate": 1
+ }
+ },
+ {
+ "alias": "chadbot",
+ "kind": "live.voice",
+ "name": "ChadBot",
+ "description": "Deep bro voice",
+ "art": {
+ "emoji": "💪"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "pitch": 0.5,
+ "rate": 0.8
+ }
+ },
+ {
+ "alias": "karen",
+ "kind": "live.voice",
+ "name": "Karen",
+ "description": "Manager-seeking",
+ "art": {
+ "emoji": "💅"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "pitch": 1.3,
+ "rate": 1.1
+ }
+ },
+ {
+ "alias": "squeakmaster",
+ "kind": "live.voice",
+ "name": "SqueakMaster",
+ "description": "Ultra-squeaky",
+ "art": {
+ "emoji": "🐭"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "pitch": 1.9,
+ "rate": 1.4
+ }
+ },
+ {
+ "alias": "bigchungus",
+ "kind": "live.voice",
+ "name": "BigChungus",
+ "description": "Absolute unit",
+ "art": {
+ "emoji": "🐰"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "pitch": 0.3,
+ "rate": 0.7
+ }
+ },
+ {
+ "alias": "tweaker",
+ "kind": "live.voice",
+ "name": "Tweaker",
+ "description": "Fast & nervous",
+ "art": {
+ "emoji": "⚡"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "pitch": 1.4,
+ "rate": 1.8
+ }
+ },
+ {
+ "alias": "grandpa",
+ "kind": "live.voice",
+ "name": "Grandpa",
+ "description": "Old & wise",
+ "art": {
+ "emoji": "👴"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "pitch": 0.7,
+ "rate": 0.7
+ }
+ },
+ {
+ "alias": "crackhead",
+ "kind": "live.voice",
+ "name": "CrackheadCarl",
+ "description": "Manic energy",
+ "art": {
+ "emoji": "💊"
+ },
+ "rarity": "uncommon",
+ "attributes": {
+ "tier": 2,
+ "pitch": 1.5,
+ "rate": 1.6
+ }
+ },
+ {
+ "alias": "ghostgirl",
+ "kind": "live.voice",
+ "name": "GhostGirl",
+ "description": "Eerie whisper",
+ "art": {
+ "emoji": "👻"
+ },
+ "rarity": "uncommon",
+ "attributes": {
+ "tier": 2,
+ "pitch": 1.6,
+ "rate": 0.8
+ }
+ },
+ {
+ "alias": "robotoverlord",
+ "kind": "live.voice",
+ "name": "RobotOverlord",
+ "description": "Machine overlord",
+ "art": {
+ "emoji": "🤖"
+ },
+ "rarity": "uncommon",
+ "attributes": {
+ "tier": 2,
+ "pitch": 0.4,
+ "rate": 0.9
+ }
+ },
+ {
+ "alias": "sassybitch",
+ "kind": "live.voice",
+ "name": "SassyBitch",
+ "description": "Sassy attitude",
+ "art": {
+ "emoji": "💁"
+ },
+ "rarity": "uncommon",
+ "attributes": {
+ "tier": 2,
+ "pitch": 1.3,
+ "rate": 1.2
+ }
+ },
+ {
+ "alias": "demon",
+ "kind": "live.voice",
+ "name": "Demon (Legacy)",
+ "description": "Demonic (espeak)",
+ "art": {
+ "emoji": "👹"
+ },
+ "rarity": "uncommon",
+ "attributes": {
+ "tier": 2,
+ "pitch": 0.2,
+ "rate": 0.5
+ }
+ },
+ {
+ "alias": "helium",
+ "kind": "live.voice",
+ "name": "Helium",
+ "description": "Squeaky helium",
+ "art": {
+ "emoji": "🎈"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "pitch": 2,
+ "rate": 1.5
+ }
+ },
+ {
+ "alias": "britbong",
+ "kind": "live.voice",
+ "name": "BritBong",
+ "description": "British accent",
+ "art": {
+ "emoji": "🇬🇧"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "pitch": 1,
+ "rate": 0.9
+ }
+ },
+ {
+ "alias": "yeehaw",
+ "kind": "live.voice",
+ "name": "YeeHaw",
+ "description": "Southern drawl",
+ "art": {
+ "emoji": "🤠"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "pitch": 0.8,
+ "rate": 0.9
+ }
+ },
+ {
+ "alias": "nyc",
+ "kind": "live.voice",
+ "name": "NYC",
+ "description": "New York accent",
+ "art": {
+ "emoji": "🗽"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "pitch": 1.1,
+ "rate": 1.3
+ }
+ },
+ {
+ "alias": "french",
+ "kind": "live.voice",
+ "name": "French",
+ "description": "French accent",
+ "art": {
+ "emoji": "🇫🇷"
+ },
+ "rarity": "common",
+ "attributes": {
+ "tier": 1,
+ "pitch": 1.1,
+ "rate": 0.8
+ }
+ },
+ {
+ "alias": "chatterbox",
+ "kind": "live.voice",
+ "name": "Chatterbox",
+ "description": "Achievement voice",
+ "art": {
+ "emoji": "💬"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "pitch": 1.2,
+ "rate": 1.4
+ }
+ },
+ {
+ "alias": "fisherman",
+ "kind": "live.voice",
+ "name": "Fisherman",
+ "description": "Achievement voice",
+ "art": {
+ "emoji": "🎣"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "pitch": 0.9,
+ "rate": 0.85
+ }
+ },
+ {
+ "alias": "gc_smooth_operator",
+ "kind": "live.voice",
+ "name": "Smooth Operator",
+ "description": "Google Cloud",
+ "art": {
+ "emoji": "🎤"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "pitch": 0.9,
+ "rate": 0.95
+ }
+ },
+ {
+ "alias": "gc_silicon_sally",
+ "kind": "live.voice",
+ "name": "Silicon Sally",
+ "description": "Google Cloud",
+ "art": {
+ "emoji": "🎤"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "pitch": 1.2,
+ "rate": 1
+ }
+ },
+ {
+ "alias": "gc_brit_butler",
+ "kind": "live.voice",
+ "name": "British Butler",
+ "description": "Google Cloud",
+ "art": {
+ "emoji": "🎤"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "pitch": 0.85,
+ "rate": 0.9
+ }
+ },
+ {
+ "alias": "gc_lady_london",
+ "kind": "live.voice",
+ "name": "Lady London",
+ "description": "Google Cloud",
+ "art": {
+ "emoji": "🎤"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "pitch": 1.15,
+ "rate": 0.95
+ }
+ },
+ {
+ "alias": "gc_mumbai_mike",
+ "kind": "live.voice",
+ "name": "Mumbai Mike",
+ "description": "Google Cloud",
+ "art": {
+ "emoji": "🎤"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "pitch": 1,
+ "rate": 1.05
+ }
+ },
+ {
+ "alias": "gc_down_under",
+ "kind": "live.voice",
+ "name": "Down Under",
+ "description": "Google Cloud",
+ "art": {
+ "emoji": "🎤"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "pitch": 0.95,
+ "rate": 1
+ }
+ },
+ {
+ "alias": "gc_sheila",
+ "kind": "live.voice",
+ "name": "Sheila",
+ "description": "Google Cloud",
+ "art": {
+ "emoji": "🎤"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "pitch": 1.25,
+ "rate": 1.05
+ }
+ },
+ {
+ "alias": "gc_studio_f",
+ "kind": "live.voice",
+ "name": "Studio Female",
+ "description": "Google Studio",
+ "art": {
+ "emoji": "🎙️"
+ },
+ "rarity": "epic",
+ "attributes": {
+ "tier": 4,
+ "pitch": 1.1,
+ "rate": 0.9
+ }
+ },
+ {
+ "alias": "gc_studio_m",
+ "kind": "live.voice",
+ "name": "Studio Male",
+ "description": "Google Studio",
+ "art": {
+ "emoji": "🎙️"
+ },
+ "rarity": "epic",
+ "attributes": {
+ "tier": 4,
+ "pitch": 0.85,
+ "rate": 0.9
+ }
+ },
+ {
+ "alias": "pl_joanna_std",
+ "kind": "live.voice",
+ "name": "Joanna",
+ "description": "Amazon Polly",
+ "art": {
+ "emoji": "🎤"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "pitch": 1.1,
+ "rate": 1
+ }
+ },
+ {
+ "alias": "pl_matthew_std",
+ "kind": "live.voice",
+ "name": "Matthew",
+ "description": "Amazon Polly",
+ "art": {
+ "emoji": "🎤"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "pitch": 0.9,
+ "rate": 1
+ }
+ },
+ {
+ "alias": "pl_amy_std",
+ "kind": "live.voice",
+ "name": "Amy",
+ "description": "Amazon Polly",
+ "art": {
+ "emoji": "🎤"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "pitch": 1.15,
+ "rate": 1
+ }
+ },
+ {
+ "alias": "pl_brian_std",
+ "kind": "live.voice",
+ "name": "Brian",
+ "description": "Amazon Polly",
+ "art": {
+ "emoji": "🎤"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "pitch": 0.85,
+ "rate": 0.95
+ }
+ },
+ {
+ "alias": "pl_olivia_std",
+ "kind": "live.voice",
+ "name": "Olivia",
+ "description": "Amazon Polly",
+ "art": {
+ "emoji": "🎤"
+ },
+ "rarity": "rare",
+ "attributes": {
+ "tier": 3,
+ "pitch": 1.2,
+ "rate": 1
+ }
+ },
+ {
+ "alias": "pl_danielle_long",
+ "kind": "live.voice",
+ "name": "Danielle",
+ "description": "Polly Long-form",
+ "art": {
+ "emoji": "🎤"
+ },
+ "rarity": "epic",
+ "attributes": {
+ "tier": 4,
+ "pitch": 1.1,
+ "rate": 0.85
+ }
+ },
+ {
+ "alias": "pl_gregory_long",
+ "kind": "live.voice",
+ "name": "Gregory",
+ "description": "Polly Long-form",
+ "art": {
+ "emoji": "🎤"
+ },
+ "rarity": "epic",
+ "attributes": {
+ "tier": 4,
+ "pitch": 0.8,
+ "rate": 0.85
+ }
+ },
+ {
+ "alias": "pl_gregory_neural",
+ "kind": "live.voice",
+ "name": "Gregory Neural",
+ "description": "Polly Neural",
+ "art": {
+ "emoji": "🧠"
+ },
+ "rarity": "epic",
+ "attributes": {
+ "tier": 4,
+ "pitch": 0.85,
+ "rate": 0.9
+ }
+ },
+ {
+ "alias": "pl_ruth_long",
+ "kind": "live.voice",
+ "name": "Ruth",
+ "description": "Polly Long-form",
+ "art": {
+ "emoji": "🎤"
+ },
+ "rarity": "epic",
+ "attributes": {
+ "tier": 4,
+ "pitch": 1,
+ "rate": 0.85
+ }
+ },
+ {
+ "alias": "pl_arthur_neural",
+ "kind": "live.voice",
+ "name": "Arthur Neural",
+ "description": "Polly Neural",
+ "art": {
+ "emoji": "🧠"
+ },
+ "rarity": "legendary",
+ "attributes": {
+ "tier": 5,
+ "pitch": 0.9,
+ "rate": 0.85
+ }
+ },
+ {
+ "alias": "vs_mine_dwarf_lord",
+ "kind": "live.voice",
+ "name": "Dwarf Lord",
+ "description": "Skill mastery",
+ "art": {
+ "emoji": "⛏️"
+ },
+ "rarity": "legendary",
+ "attributes": {
+ "tier": 5,
+ "pitch": 0.5,
+ "rate": 0.7
+ }
+ }
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+'use strict';
+
+/**
+ * OpenVibe.Inventory's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot. Nothing
+ * written here is ever a secret, token or credential.
+ */
+const fs = require('fs');
+const path = require('path');
+const { createDb } = require('openvibe-sdk/db');
+const { ids } = require('openvibe-contracts');
+
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
+
+async function openDb(config, { log = console, registry } = {}) {
+ if (!config.db.url) {
+ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh inventory)');
+ const dir = config.db.pgliteDir || DEV_PGLITE;
+ log.warn(`[OpenVibe.Inventory] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
+ fs.mkdirSync(dir, { recursive: true });
+ const db = createDb({ pglite: dir, service: 'inventory', registry, log });
+ await db.migrate({ dir: MIGRATIONS, log });
+ return db;
+ }
+ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
+ const owner = createDb({ url: config.db.directUrl, service: 'inventory-migrate', max: 1, log });
+ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+ return createDb({ url: config.db.url, service: 'inventory', registry, log });
+}
+
+/** The store every module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */
+function createStore(db, { now = () => Date.now() } = {}) {
+ return {
+ db,
+ now,
+ iso: () => new Date(now()).toISOString(),
+ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,
+ tx: async (fn) => await db.tx(() => fn()),
+ close: () => db.close(),
+ };
+}
+
+async function openStore(config, { now, log } = {}) {
+ return createStore(await openDb(config, { log }), { now });
+}
+
+module.exports = { openDb, openStore, createStore, MIGRATIONS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+
+/**
+ * /api/v1 — OpenVibe.Inventory's API (ADR-054; the capabilities are openvibe-contracts manifests/capabilities/inventory.*).
+ *
+ * GET /ping public liveness
+ * GET /kinds, /kinds/:id inventory.item.read the kinds (inventory.kind@1)
+ * GET /definitions?kind=&issuer= inventory.item.read published definitions (inventory.definition@1)
+ * GET /definitions/:id inventory.item.read one definition (any status, for its issuer)
+ * GET /people/:subject/items?kind=&cursor= inventory.item.read a person's public inventory (inventory.inventory@1)
+ * GET /people/:subject/equipped inventory.item.read their equipped set (inventory.equipped@1)
+ * GET /equipped?subjects=a,b,… inventory.item.read up to 100 equipped sets (inventory.equipped-batch@1)
+ * GET /me/items inventory.item.list the caller's own inventory, every state
+ * PUT /me/equipped inventory.equip.manage equip or clear a slot (inventory.equip-request@1)
+ * POST /grants inventory.item.grant an issuer grants an instance (inventory.grant-request@1)
+ * POST /instances/:id/consume | /revoke inventory.item.consume an issuer uses up or takes back an instance
+ * POST /definitions, PATCH /definitions/:id inventory.definition.manage an issuer's definitions
+ *
+ * A person acts for themself (their token or this site's session). A service or app acting for a person sends that
+ * person's subject in X-OV-Subject and holds the route's capability. Nothing here sells, buys, trades or converts an
+ * item (ADR-054 §5). A refusal is an RFC 9457 problem+json with a stable code.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { asyncRouter } = require('./router');
+const { InventoryError, SUBJECT_RE } = require('../inventory/store');
+
+function createApi(ctx) {
+ const { config, principal, limits, inv } = ctx;
+ const r = asyncRouter();
+ const read = principal.requireRead('inventory.item.read');
+
+ r.use(express.json({ limit: '64kb' }));
+ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+ r.use(principal.middleware);
+
+ /** The person a request acts for: a person's own subject, or a service's X-OV-Subject. */
+ function personOf(req, res) {
+ const p = req.principal;
+ if (p.kind === 'user') return p.requester.slice(5);
+ const s = String(req.get('x-ov-subject') || '');
+ if (SUBJECT_RE.test(s)) return s;
+ http.sendProblem(res, 400, 'inventory.subject_required', { detail: 'A service acting for a person sends X-OV-Subject: usr_…', ctx: req.ov });
+ return null;
+ }
+ const issuerOf = (req) => req.principal.requester;
+ const wrap = (fn) => async (req, res) => {
+ try { return await fn(req, res); } catch (err) {
+ if (err instanceof InventoryError) return http.sendProblem(res, err.status, err.code, { detail: err.detail, ctx: req.ov });
+ throw err;
+ }
+ };
+
+ r.get('/ping', limits.reads('inventory.item.read'), (_req, res) => res.json({ ok: true, service: config.service }));
+
+ // ── Public reads ──────────────────────────────────────
+ r.get('/kinds', read, limits.reads('inventory.item.read'), wrap(async (_req, res) => res.json({ kinds: await inv.listKinds() })));
+ r.get('/kinds/:id', read, limits.reads('inventory.item.read'), wrap(async (req, res) => {
+ const k = await inv.getKind(req.params.id);
+ return k ? res.json({ kind: k }) : http.sendProblem(res, 404, 'inventory.unknown_kind', { detail: 'no such kind', ctx: req.ov });
+ }));
+ r.get('/definitions', read, limits.reads('inventory.item.read'), wrap(async (req, res) => res.json({
+ definitions: await inv.listDefinitions({ kind: req.query.kind ? String(req.query.kind) : null, issuer: req.query.issuer ? String(req.query.issuer) : null, limit: req.query.limit }),
+ })));
+ r.get('/definitions/:id', read, limits.reads('inventory.item.read'), wrap(async (req, res) => {
+ const d = await inv.getDefinition(req.params.id);
+ // A draft or an item in review is its issuer's to see.
+ if (!d || (d.status !== 'published' && d.status !== 'retired' && req.principal.requester !== d.issuer)) return http.sendProblem(res, 404, 'inventory.definition_not_found', { detail: 'no such definition', ctx: req.ov });
+ return res.json({ definition: d });
+ }));
+ r.get('/people/:subject/items', read, limits.reads('inventory.item.read'), wrap(async (req, res) => res.json(await inv.inventory(req.params.subject, {
+ cursor: req.query.cursor || null, limit: req.query.limit, kind: req.query.kind ? String(req.query.kind) : null,
+ }))));
+ r.get('/people/:subject/equipped', read, limits.reads('inventory.item.read'), wrap(async (req, res) => {
+ if (!SUBJECT_RE.test(req.params.subject)) return http.sendProblem(res, 404, 'inventory.unknown_subject', { detail: 'a usr_ subject', ctx: req.ov });
+ return res.json((await inv.equipped([req.params.subject]))[0]);
+ }));
+ r.get('/equipped', read, limits.reads('inventory.item.read'), wrap(async (req, res) => {
+ const subjects = String(req.query.subjects || '').split(',').map((x) => x.trim()).filter(Boolean);
+ if (subjects.length > 100) return http.sendProblem(res, 400, 'inventory.too_many_subjects', { detail: 'at most 100 subjects per call', ctx: req.ov });
+ return res.json({ equipped: await inv.equipped(subjects) });
+ }));
+
+ // ── The person's own ──────────────────────────────────
+ r.get('/me/items', principal.requireCapability('inventory.item.list'), limits.reads('inventory.item.list'), wrap(async (req, res) => {
+ const subject = personOf(req, res);
+ if (!subject) return undefined;
+ return res.json(await inv.inventory(subject, { own: true, cursor: req.query.cursor || null, limit: req.query.limit, kind: req.query.kind ? String(req.query.kind) : null }));
+ }));
+ r.put('/me/equipped', principal.requireCapability('inventory.equip.manage'), limits.budget('inventory.equip'), wrap(async (req, res) => {
+ const subject = personOf(req, res);
+ if (!subject) return undefined;
+ return res.json(await inv.equip(subject, req.principal.kind === 'user' ? req.principal.requester : issuerOf(req), req.body));
+ }));
+
+ // ── Issuers ───────────────────────────────────────────
+ r.post('/grants', principal.requireCapability('inventory.item.grant'), limits.budget('inventory.grant'), wrap(async (req, res) => {
+ const out = await inv.grant(issuerOf(req), req.body);
+ return res.status(out.created ? 201 : 200).json({ instance: out.instance, created: out.created });
+ }));
+ r.post('/instances/:id/consume', principal.requireCapability('inventory.item.consume'), limits.budget('inventory.grant'), wrap(async (req, res) => res.json({ instance: await inv.consume(issuerOf(req), req.params.id) })));
+ r.post('/instances/:id/revoke', principal.requireCapability('inventory.item.consume'), limits.budget('inventory.grant'), wrap(async (req, res) => res.json({
+ instance: await inv.consume(issuerOf(req), req.params.id, { revoke: true, reason: req.body && typeof req.body.reason === 'string' ? req.body.reason.slice(0, 200) : null }),
+ })));
+ r.post('/definitions', principal.requireCapability('inventory.definition.manage'), limits.budget('inventory.define'), wrap(async (req, res) => res.status(201).json({ definition: await inv.createDefinition(issuerOf(req), req.body) })));
+ r.patch('/definitions/:id', principal.requireCapability('inventory.definition.manage'), limits.budget('inventory.define'), wrap(async (req, res) => res.json({ definition: await inv.updateDefinition(issuerOf(req), req.params.id, req.body) })));
+
+ return r;
+}
+
+module.exports = { createApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+
+/**
+ * Per-caller rate limits at OpenVibe.Inventory's API (openvibe-sdk/limits). The per-address limits in app.js stay; these
+ * count requests by who makes them:
+ *
+ * a person user:usr_… (a Bearer token or this site's session)
+ * an app, agent, service app:app_… / agent:agt_… / service:<slug>
+ * anyone else ip:<address>
+ *
+ * Past a limit the route answers 429 problem+json `rate_limited` with Retry-After before it does any work; the
+ * refusal is logged once and counted in inventory_rate_limited_total{limit,window}. Reads take INVENTORY_LIMITS_MINUTE /
+ * INVENTORY_LIMITS_HOUR (120 and 3000); the product's expensive routes get their own numbers in BUDGETS below.
+ * Counters live in this process unless VALKEY_URL is set.
+ *
+ * Never limited here: /api/health, /api/ready, /release.json, /metrics, sign-in and the pages.
+ */
+const { createActorLimiter, createValkeyLimitStore } = require('openvibe-sdk/limits');
+
+function caller(req) {
+ const p = req.principal;
+ if (p && p.requester) return p.requester;
+ const v = req.viewer;
+ if (v && v.kind === 'user' && v.subject) return `user:${v.subject}`;
+ return `ip:${req.ip || (req.socket && req.socket.remoteAddress) || 'unknown'}`;
+}
+
+/**
+ * The product's expensive routes, each with its numbers per caller (a minute, an hour). A route takes its entry
+ * with `limits.budget('<name>')`; an unknown name throws, so a route can never be counted by a budget that was
+ * not declared here, e.g.
+ *
+ * 'inventory.thing.create': { minute: 6, hour: 60 },
+ */
+const BUDGETS = {
+ // Equipping: a person changing what they wear (a few dozen a minute is a lot).
+ 'inventory.equip': { minute: 30, hour: 600 },
+ // Grants, consumes and revokes by an issuer: games and events grant in bursts.
+ 'inventory.grant': { minute: 600, hour: 20000 },
+ // Defining items.
+ 'inventory.define': { minute: 30, hour: 300 },
+};
+
+function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
+ const refused = registry
+ ? registry.counter({ name: 'inventory_rate_limited_total', help: 'Requests refused 429 by a per-caller limit, by limit name and window', labelNames: ['limit', 'window'] })
+ : null;
+ const limiter = createActorLimiter({
+ limits: { minute: config.limits.minute, hour: config.limits.hour },
+ actor: enabled ? caller : () => null,
+ now,
+ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),
+ onLimited(e) {
+ // The caller is a subject, a principal or an address, never a token.
+ log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
+ if (refused) refused.inc({ limit: e.name, window: e.window });
+ },
+ });
+ limiter.reads = (name) => {
+ const limit = limiter(name);
+ return (req, res, next) => (req.method === 'GET' || req.method === 'HEAD' ? limit(req, res, next) : next());
+ };
+ const budgets = new Map(Object.entries(BUDGETS).map(([name, own]) => [name, limiter(name, own)]));
+ limiter.budget = (name) => {
+ const m = budgets.get(name);
+ if (!m) throw new Error(`limits: no budget named ${name}`);
+ return m;
+ };
+ return limiter;
+}
+
+module.exports = { createCallerLimits, caller, BUDGETS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+'use strict';
+
+/**
+ * Crawl artifacts for inventory.openvibe.network, built with openvibe-shared/seo: robots.txt, sitemap.xml, llms.txt and
+ * llms-full.txt, and the home page's JSON-LD. The public pages are for search engines and AI crawlers; sign-in and
+ * the API are not.
+ *
+ * The product extends PAGE_TEXT and publicPages() with its own pages; the routes and headers here stay.
+ */
+const fs = require('fs');
+const path = require('path');
+const seo = require('openvibe-shared/seo');
+const cache = require('openvibe-shared/cache-policy');
+const { asyncRouter } = require('./router');
+
+const SITE_NAME = 'OpenVibe.Inventory';
+const DESCRIPTION = 'OpenVibe.Inventory — Your items, on every OpenVibe site.';
+const DISALLOW = ['/auth/', '/api/'];
+
+const PAGE_TEXT = {
+ '/': ['OpenVibe.Inventory home', 'OpenVibe.Inventory: Your items, on every OpenVibe site.'],
+ '/updates': ['What shipped on OpenVibe.Inventory', 'This site\'s update log, from the network changelog feed.'],
+};
+
+function dayOf(ts) {
+ const m = String(ts == null ? '' : ts).match(/^(\d{4}-\d{2}-\d{2})/);
+ return m ? m[1] : null;
+}
+function siteUpdated() {
+ try { return dayOf(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated); } catch { return null; }
+}
+
+function homeJsonLd(config) {
+ const site = String(config.baseUrl).replace(/\/+$/, '');
+ return [
+ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
+ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'BusinessApplication', keywords: 'openvibe' }),
+ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
+ ];
+}
+
+const publicPages = () => [
+ { path: '/', changefreq: 'weekly', priority: 1.0 },
+ { path: '/updates', changefreq: 'daily', priority: 0.5 },
+];
+
+function createDiscoveryRoutes(ctx) {
+ const { config } = ctx;
+ const r = asyncRouter();
+ const site = String(config.baseUrl).replace(/\/+$/, '');
+ const abs = (p) => `${site}${p}`;
+ const TEXT = cache.htmlHeaders({ maxAge: 3600 });
+
+ r.get('/robots.txt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', TEXT).send(
+ '# inventory.openvibe.network: the public pages are for search and AI crawlers; sign-in and the API are not.\n'
+ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
+ });
+
+ r.get('/llms.txt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
+ name: SITE_NAME,
+ summary: 'OpenVibe.Inventory: Your items, on every OpenVibe site.',
+ details: 'Every page is server-rendered and readable without JavaScript.',
+ sections: [
+ { title: 'Start here', links: [
+ { title: 'OpenVibe.Inventory', url: abs('/'), note: 'Your items, on every OpenVibe site.' },
+ { title: 'What shipped on OpenVibe.Inventory', url: abs('/updates') },
+ ] },
+ { title: 'Machine-readable', links: [
+ { title: 'Sitemap', url: abs('/sitemap.xml') },
+ { title: 'Full text for language models', url: abs('/llms-full.txt') },
+ { title: 'Release metadata (JSON)', url: abs('/release.json') },
+ ] },
+ { title: 'Elsewhere', links: [
+ { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts, apps and grants' },
+ { title: 'OpenVibe.Services', url: 'https://openvibe.services', note: 'apps, keys and capability grants' },
+ ] },
+ ],
+ }));
+ });
+
+ r.get('/llms-full.txt', (_req, res) => {
+ const pages = publicPages().map((p) => ({ url: p.path, title: PAGE_TEXT[p.path][0], text: PAGE_TEXT[p.path][1] }));
+ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
+ site: SITE_NAME,
+ summary: 'Every public page of OpenVibe.Inventory, one line each.',
+ base: site,
+ maxBytes: 64 * 1024,
+ sections: [{ title: 'Pages', pages }],
+ }));
+ });
+
+ r.get('/sitemap.xml', (_req, res) => {
+ const lastmod = siteUpdated();
+ const urls = publicPages().map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
+ res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
+ });
+
+ return r;
+}
+
+module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, DESCRIPTION, SITE_NAME };
new file mode 100644
new file mode 100644
@@ -0,0 +1,244 @@
@@ -0,0 +1,244 @@
+'use strict';
+
+/**
+ * Pages (ADR-054). Every page works without JavaScript and is server-rendered through openvibe-shared/shell.
+ *
+ * / what the inventory is, its kinds, a few items, how items arrive
+ * /items every published item, by kind (?kind=), with its rarity
+ * /items/:id one item: what it is, where it shows, who issues it, how many exist
+ * /me the signed-in person's items; equip or take off with plain forms (POST /me/equip)
+ * /u/:subject a person's public inventory and what they wear
+ * /updates the update log
+ *
+ * Nothing here sells, buys, trades or converts an item (ADR-054 §5).
+ */
+const ovServe = require('openvibe-shared/serve');
+const frame = require('openvibe-shared/frame');
+const showcase = require('openvibe-shared/showcase');
+const cache = require('openvibe-shared/cache-policy');
+const express = require('express');
+const { asyncRouter } = require('./router');
+const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
+const { sameOrigin } = require('./principal');
+const { html, raw } = require('../render/html');
+const { send } = require('../render/layout');
+const { InventoryError, SUBJECT_RE, RARITY } = require('../inventory/store');
+
+const SITE_NAME = 'OpenVibe.Inventory';
+const TAGLINE = 'Your items, on every OpenVibe site.';
+const RARITY_LABEL = { common: 'Common', uncommon: 'Uncommon', rare: 'Rare', epic: 'Epic', legendary: 'Legendary' };
+const SURFACE_LABEL = { chat: 'chat', overlay: 'stream overlays', profile: 'your profile' };
+// The ring icon each kind's card shows on the home page (openvibe-shared ov-icons); another kind gets the generic one.
+const KIND_ICON = { 'live.name_effect': 'ov:text', 'live.particle': 'ov:theme', 'live.hat': 'ov:account', 'live.voice': 'ov:audio', 'live.chat_tag': 'ov:chat' };
+const surfaceLabel = (s) => SURFACE_LABEL[s] || (s.startsWith('game:') ? `the game ${s.slice(5)}` : s);
+const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
+
+/** The item's picture: its emoji, or a Media image, in a square tile tinted by rarity. */
+function art(d, { size = 'md' } = {}) {
+ const a = d.art || {};
+ if (a.media_id) return html`<span class="inv-art inv-art-${size} r-${d.rarity}"><img src="https://openvibe.media/o/${a.media_id}" alt="" loading="lazy" width="64" height="64"></span>`;
+ return html`<span class="inv-art inv-art-${size} r-${d.rarity}" aria-hidden="true">${a.emoji || '◆'}</span>`;
+}
+const rarityBadge = (r) => html`<span class="inv-rarity r-${r}">${RARITY_LABEL[r] || r}</span>`;
+
+function card(d, { href = `/items/${d.id}`, extra = '', owned = null, worn = false } = {}) {
+ return html`<li class="inv-card r-${d.rarity}${worn ? ' is-worn' : ''}">
+ <a class="inv-card-link" href="${href}">${art(d)}<span class="inv-card-body"><span class="inv-card-name">${d.name}</span>${rarityBadge(d.rarity)}</span></a>
+ ${d.description ? html`<p class="inv-card-desc">${d.description}</p>` : ''}${owned ? html`<p class="inv-card-meta">${owned}</p>` : ''}${extra}
+</li>`;
+}
+
+function createPageRoutes(ctx) {
+ const { config, inv } = ctx;
+ const r = asyncRouter();
+ const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
+ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
+ const signedIn = (req) => (req.viewer && req.viewer.kind === 'user' && SUBJECT_RE.test(String(req.viewer.subject || '')) ? req.viewer.subject : null);
+ const notFound = (req, res, what) => page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>${what} <a href="/items">Browse every item</a>.</p>` }, 404);
+
+ async function catalog() {
+ const [kinds, defs] = await Promise.all([inv.listKinds(), inv.listDefinitions({ limit: 500 })]);
+ const byKind = new Map(kinds.map((k) => [k.id, []]));
+ for (const d of defs) if (byKind.has(d.kind)) byKind.get(d.kind).push(d);
+ const order = (a, b) => RARITY.indexOf(b.rarity) - RARITY.indexOf(a.rarity) || a.name.localeCompare(b.name);
+ for (const list of byKind.values()) list.sort(order);
+ return { kinds, defs, byKind };
+ }
+
+ // ── Home ─────────────────────────────────────────────────
+ r.get('/', async (req, res) => {
+ const { kinds, defs, byKind } = await catalog();
+ const featured = [...defs].sort((a, b) => RARITY.indexOf(b.rarity) - RARITY.indexOf(a.rarity) || a.name.localeCompare(b.name)).slice(0, 6);
+ const me = signedIn(req);
+ const hero = showcase.hero({
+ eyebrow: `${SITE_NAME} · part of OpenVibe`,
+ title: 'Your items,',
+ accent: 'on every OpenVibe site.',
+ lede: 'One inventory for your whole OpenVibe account: name effects, particles, hats, voices and more. Earn them across the network, wear them in chat, on stream overlays and on your profile.',
+ actions: me
+ ? [{ label: 'Your inventory', href: '/me', primary: true }, { label: 'Browse every item', href: '/items' }]
+ : [{ label: 'Browse every item', href: '/items', primary: true }, { label: 'Sign in to see yours', href: '/auth/login?next=%2Fme' }],
+ note: 'Items are earned or given, never sold here. Open source (AGPL-3.0).',
+ aside: { html: html`<div class="inv-hero-panel" aria-label="A few of the rarest items"><p class="inv-hero-title">From the collection</p><ul class="inv-hero-grid">${featured.map((d) => html`<li><a href="/items/${d.id}" title="${d.name} · ${RARITY_LABEL[d.rarity]}">${art(d, { size: 'lg' })}<span class="inv-hero-name">${d.name}</span></a></li>`)}</ul></div>`.toString() },
+ });
+ page(req, res, {
+ index: true, cache: me ? null : PUBLIC_CACHE,
+ jsonLd: homeJsonLd(config),
+ styles: [showcase.STYLESHEET],
+ body: html`${raw(hero)}
+${raw(showcase.features({
+ title: 'What you can collect',
+ lede: `${plural(defs.length, 'item', 'items')} in ${plural(kinds.length, 'kind', 'kinds')} today. Each kind knows where it shows, so a new kind is all it takes for a new site or game to join.`,
+ // Kinds that have items, the biggest first; an empty kind waits until an issuer defines something in it.
+ items: kinds.filter((k) => (byKind.get(k.id) || []).length).sort((a, b) => byKind.get(b.id).length - byKind.get(a.id).length).map((k) => ({ icon: KIND_ICON[k.id] || 'ov:ov', title: `${k.name} · ${(byKind.get(k.id) || []).length}`, text: `${k.description || ''} Shows in ${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ')}.`, href: `/items?kind=${encodeURIComponent(k.id)}` })),
+ }))}
+${raw(showcase.steps({
+ title: 'How items reach you',
+ items: [
+ { title: 'Earn or receive them', text: 'Sites, games and quests across the network give items for what you do. Each one can only give its own items.' },
+ { title: 'Keep them in one place', text: 'Everything you own is here, with where it came from and when.' },
+ { title: 'Wear them everywhere', text: 'Equip one item per slot. Every site that shows that kind shows what you chose.' },
+ { title: 'Never for sale', text: 'Nothing here is bought, sold, traded or turned into money. Rarity is shown as it is.' },
+ ],
+ }))}
+${raw(showcase.cta({ title: me ? 'See what you have' : 'Already earned something?', text: me ? 'Your items and what you are wearing, on one page.' : 'Sign in with your OpenVibe account to see your inventory and choose what to wear.', actions: me ? [{ label: 'Your inventory', href: '/me' }] : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Fme' }, { label: 'The API', href: '/api/v1/kinds' }] }))}`,
+ });
+ });
+
+ // ── The catalog ──────────────────────────────────────────
+ r.get('/items', async (req, res) => {
+ const { kinds, defs, byKind } = await catalog();
+ const only = req.query.kind ? String(req.query.kind) : null;
+ if (only && !byKind.has(only)) return notFound(req, res, 'There is no such kind of item.');
+ // All: the kinds that have items; one kind: that kind, even while it is empty.
+ const shown = kinds.filter((k) => (only ? k.id === only : (byKind.get(k.id) || []).length));
+ page(req, res, {
+ index: !only, cache: signedIn(req) ? null : PUBLIC_CACHE,
+ title: only ? `${shown[0].name} items` : 'Every item',
+ description: `${plural(defs.length, 'item', 'items')} you can earn and wear across OpenVibe, with their rarity.`,
+ body: html`<h1>${only ? `${shown[0].name} items` : 'Every item'}</h1>
+<p class="lede">${only ? shown[0].description : `${plural(defs.length, 'item', 'items')} in ${plural(kinds.length, 'kind', 'kinds')}. Rarity is shown as it is.`}</p>
+<nav class="inv-tabs" aria-label="Kinds"><a href="/items"${only ? '' : raw(' aria-current="page"')}>All</a>${kinds.map((k) => html`<a href="/items?kind=${encodeURIComponent(k.id)}"${only === k.id ? raw(' aria-current="page"') : ''}>${k.name} <span class="muted">${(byKind.get(k.id) || []).length}</span></a>`)}</nav>
+${shown.map((k) => html`<section class="inv-kind" aria-labelledby="k-${k.id.replace(/\W/g, '-')}">
+ <h2 id="k-${k.id.replace(/\W/g, '-')}">${k.name} <span class="muted small">shows in ${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ')}</span></h2>
+ ${(byKind.get(k.id) || []).length ? html`<ul class="inv-grid">${byKind.get(k.id).map((d) => card(d))}</ul>` : html`<p class="muted">No ${k.name.toLowerCase()} items yet.</p>`}
+</section>`)}`,
+ });
+ });
+
+ r.get('/items/:id', async (req, res) => {
+ const d = await inv.getDefinition(req.params.id);
+ if (!d || (d.status !== 'published' && d.status !== 'retired')) return notFound(req, res, 'There is no such item.');
+ const k = await inv.getKind(d.kind);
+ const issuer = d.issuer.startsWith('service:') ? `OpenVibe.${d.issuer.slice(8).replace(/^./, (c) => c.toUpperCase())}` : d.credit && d.credit.name ? d.credit.name : 'a creator';
+ page(req, res, {
+ index: d.status === 'published', cache: signedIn(req) ? null : PUBLIC_CACHE,
+ title: `${d.name} · ${RARITY_LABEL[d.rarity]} ${k.name.toLowerCase()}`,
+ description: d.description || `${d.name}, a ${RARITY_LABEL[d.rarity].toLowerCase()} ${k.name.toLowerCase()} on OpenVibe.`,
+ body: html`<p class="crumbs"><a href="/items">Every item</a> › <a href="/items?kind=${encodeURIComponent(k.id)}">${k.name}</a></p>
+<div class="inv-detail r-${d.rarity}">
+ ${art(d, { size: 'xl' })}
+ <div>
+ <h1>${d.name}</h1>
+ <p>${rarityBadge(d.rarity)} <span class="muted">${k.name}</span>${d.status === 'retired' ? html` <span class="badge warn">No longer given</span>` : ''}</p>
+ ${d.description ? html`<p class="lede">${d.description}</p>` : ''}
+ </div>
+</div>
+<ul class="stats">
+ <li><b>${d.supply.issued}</b> owned across OpenVibe</li>
+ <li><b>${d.supply.cap == null ? 'Unlimited' : `${d.supply.cap - d.supply.issued} of ${d.supply.cap}`}</b> ${d.supply.cap == null ? 'supply' : 'left'}</li>
+ <li><b>${k.slots.length ? 'Wearable' : 'Collectible'}</b> ${k.slots.length ? `in the ${k.slots.join(', ')} slot` : 'shown on your profile'}</li>
+</ul>
+<h2>Where it shows</h2>
+<p>${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ').replace(/^./, (c) => c.toUpperCase())}.</p>
+<h2>Who gives it</h2>
+<p>${issuer}. Only its issuer can give this item; it is never sold here.</p>
+<p class="muted small">Item <code>${d.id}</code>${(d.aliases || []).length ? html` · also known as <code>${d.aliases.join(', ')}</code>` : ''} · <a href="/api/v1/definitions/${d.id}">JSON</a></p>`,
+ });
+ });
+
+ // ── Your inventory ───────────────────────────────────────
+ async function ownedView(subject, { own }) {
+ const [list, eq, kinds] = await Promise.all([inv.inventory(subject, { own, limit: 200 }), inv.equipped([subject]), inv.listKinds()]);
+ const worn = new Set(Object.values(eq[0] ? eq[0].slots : {}).map((x) => x.instance_id));
+ const owned = list.instances.filter((i) => i.state === 'owned');
+ const byKind = new Map(kinds.map((k) => [k.id, []]));
+ for (const i of owned) { const d = list.definitions[i.definition_id]; if (d && byKind.has(d.kind)) byKind.get(d.kind).push({ i, d }); }
+ return { kinds, byKind, worn, owned, more: !!list.next_cursor };
+ }
+
+ r.get('/me', async (req, res) => {
+ const me = signedIn(req);
+ if (!me) return res.redirect(303, '/auth/login?next=%2Fme');
+ const { kinds, byKind, worn, owned, more } = await ownedView(me, { own: true });
+ const done = req.query.done ? String(req.query.done) : null;
+ const error = req.query.error ? String(req.query.error).slice(0, 200) : null;
+ page(req, res, {
+ title: 'Your inventory',
+ body: html`<h1>Your inventory</h1>
+<p class="lede">${owned.length ? `${plural(owned.length, 'item', 'items')}. Choose one per slot to wear it on every site that shows it.` : 'Nothing yet. Items arrive as you take part across OpenVibe: streams, quests, games and events.'}</p>
+${done ? html`<p class="notice ok" role="status">${done === 'equipped' ? 'Equipped. It shows everywhere that kind is shown.' : 'Taken off.'}</p>` : ''}
+${error ? html`<p class="notice bad" role="alert">${error}</p>` : ''}
+<p><a href="/u/${me}">See your public page</a> · <a href="/items">Browse every item</a></p>
+${kinds.filter((k) => byKind.get(k.id).length).map((k) => html`<section class="inv-kind">
+ <h2>${k.name} <span class="muted small">${plural(byKind.get(k.id).length, 'item', 'items')}</span></h2>
+ <ul class="inv-grid">${byKind.get(k.id).map(({ i, d }) => card(d, {
+ owned: `Since ${String(i.acquired_at).slice(0, 10)}${i.serial ? ` · #${i.serial}` : ''}`,
+ worn: worn.has(i.id),
+ extra: k.slots.length ? html`<form method="post" action="/me/equip" class="inv-equip">
+ <input type="hidden" name="kind" value="${k.id}"><input type="hidden" name="slot" value="${k.slots[0]}">
+ ${worn.has(i.id)
+ ? html`<input type="hidden" name="instance_id" value=""><span class="inv-worn">Wearing</span><button type="submit" class="inv-btn">Take off</button>`
+ : html`<input type="hidden" name="instance_id" value="${i.id}"><button type="submit" class="inv-btn primary" aria-label="Wear ${d.name}">Wear</button>`}
+ </form>` : '',
+ }))}</ul>
+</section>`)}
+${more ? html`<p class="muted">Showing the newest 200. The full list is at <a href="/api/v1/me/items">/api/v1/me/items</a>.</p>` : ''}`,
+ });
+ });
+
+ r.post('/me/equip', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
+ const me = signedIn(req);
+ if (!me) return res.redirect(303, '/auth/login?next=%2Fme');
+ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('That form must come from this site.');
+ const b = req.body || {};
+ try {
+ await inv.equip(me, `user:${me}`, { kind: String(b.kind || ''), slot: String(b.slot || ''), instance_id: b.instance_id ? String(b.instance_id) : null });
+ return res.redirect(303, `/me?done=${b.instance_id ? 'equipped' : 'cleared'}`);
+ } catch (err) {
+ if (err instanceof InventoryError) return res.redirect(303, `/me?error=${encodeURIComponent(err.detail || err.code)}`);
+ throw err;
+ }
+ });
+
+ // ── A person's public page ───────────────────────────────
+ r.get('/u/:subject', async (req, res) => {
+ const subject = String(req.params.subject);
+ if (!SUBJECT_RE.test(subject)) return notFound(req, res, 'There is no such person.');
+ const { kinds, byKind, worn, owned } = await ownedView(subject, { own: false });
+ const mine = signedIn(req) === subject;
+ const wornItems = owned.filter((i) => worn.has(i.id));
+ page(req, res, {
+ index: false, cache: signedIn(req) ? null : PUBLIC_CACHE,
+ title: mine ? 'Your public inventory' : 'An OpenVibe inventory',
+ body: html`<h1>${mine ? 'Your public inventory' : 'An OpenVibe inventory'}</h1>
+<p class="lede">${plural(owned.length, 'item', 'items')}${wornItems.length ? `, ${wornItems.length} worn` : ''}.${mine ? ' This is what anyone sees.' : ''}</p>
+${kinds.filter((k) => byKind.get(k.id).length).map((k) => html`<section class="inv-kind"><h2>${k.name}</h2>
+ <ul class="inv-grid">${byKind.get(k.id).map(({ i, d }) => card(d, { worn: worn.has(i.id), extra: worn.has(i.id) ? html`<p class="inv-worn">Wearing</p>` : '' }))}</ul></section>`)}
+${!owned.length ? html`<p class="muted">Nothing to show yet.</p>` : ''}`,
+ });
+ });
+
+ // ── The update log ───────────────────────────────────────
+ r.get('/updates', (req, res) => page(req, res, {
+ index: true, cache: PUBLIC_CACHE,
+ title: `What shipped on ${SITE_NAME}`,
+ body: raw(frame.updatesBody({ service: 'inventory', siteName: SITE_NAME }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`),
+ }));
+
+ // ── Discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt ──
+ r.use(createDiscoveryRoutes(ctx));
+ return r;
+}
+
+module.exports = { createPageRoutes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
+'use strict';
+
+/**
+ * Who is calling /api/v1 — req.principal:
+ *
+ * { kind: 'user', requester: 'user:usr_…', project: null, viaSession } a person: their Network token as a Bearer,
+ * or this site's session cookie
+ * { kind: 'app', requester: 'app:app_…' | 'agent:agt_…' | 'service:x', project: 'prj_…' | null, claims }
+ * a Network app, agent or service token for
+ * audience inventory.openvibe.network; each route names
+ * ONE capability it needs
+ * { kind: 'anonymous' }
+ *
+ * A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the
+ * session or to anonymous. A write made with the session cookie must come from this site (Sec-Fetch-Site
+ * same-origin, or an Origin equal to ours): another site cannot make a signed-in visitor act here.
+ * A person acting for themself needs no capability; an app, agent or service needs the one the route names.
+ */
+const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
+
+const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+// The product fills this in: one entry per capability its routes name, in openvibe-contracts
+// manifests/capabilities/<inventory>.*. requireCapability('<name>') refuses a name that is not listed here, so a
+// route can never be guarded by a capability the service does not declare.
+const CAPABILITIES = [
+ 'inventory.item.read', 'inventory.item.list', 'inventory.equip.manage', 'inventory.item.grant', 'inventory.item.consume',
+ 'inventory.definition.manage', 'inventory.definition.review',
+];
+
+function decodePayload(token) {
+ const parts = String(token || '').split('.');
+ if (parts.length !== 3) return null;
+ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }
+}
+
+function requesterOfSub(sub) {
+ if (sub.startsWith('svc:')) return `service:${sub.slice(4)}`;
+ return sub; // app:app_…, agent:agt_…, mod:mod_…
+}
+
+function sameOrigin(req, baseUrl) {
+ const site = String(req.get('sec-fetch-site') || '');
+ if (site) return site === 'same-origin';
+ const origin = req.get('origin');
+ if (origin) return origin === baseUrl || origin === `${req.protocol}://${req.get('host')}`;
+ return false;
+}
+
+function createPrincipal({ config, keys }) {
+ async function resolve(req) {
+ const header = String(req.headers.authorization || '');
+ if (header.startsWith('Bearer ')) {
+ const token = header.slice(7).trim();
+ const payload = decodePayload(token);
+ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
+ const publicKey = await keys.pemForToken(token);
+ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
+ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });
+ if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };
+ const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;
+ return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, claims: r.claims } };
+ }
+ // A person's own Network token (the same kind this site's session holds).
+ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience });
+ if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };
+ if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };
+ if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
+ return { principal: { kind: 'user', requester: `user:${v.claims.subject_id}`, project: null, viaSession: false } };
+ }
+ const viewer = req.viewer;
+ if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {
+ return { principal: { kind: 'user', requester: `user:${viewer.subject}`, project: null, viaSession: true } };
+ }
+ return { principal: { kind: 'anonymous' } };
+ }
+
+ async function middleware(req, res, next) {
+ const r = await resolve(req);
+ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });
+ req.principal = r.principal;
+ return next();
+ }
+
+ /** One route's guard: a person needs nothing more (writes by cookie must be same-origin); an app the capability. */
+ function require_(cap) {
+ if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.Inventory's capabilities`);
+ return function guard(req, res, next) {
+ const p = req.principal;
+ if (p.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at inventory.openvibe.network, or send a Network token (a person\'s, or an app\'s with this capability for inventory.openvibe.network).', ctx: req.ov });
+ if (p.kind === 'user') {
+ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'request.cross_site', { detail: 'A signed-in request that changes something must come from inventory.openvibe.network itself.', ctx: req.ov });
+ return next();
+ }
+ const decision = capabilities.check(p.claims, cap);
+ if (decision.allowed) return next();
+ return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${cap} not granted`, ctx: req.ov });
+ };
+ }
+
+ /**
+ * A public read's guard: anyone may read without a token; a token that is presented must hold the capability (an
+ * app, agent or service reading on its own account), and a person reads with their own.
+ */
+ function requireRead(cap) {
+ if (!CAPABILITIES.includes(cap)) throw new Error(`requireRead(${cap}): not one of OpenVibe.Inventory's capabilities`);
+ return function readGuard(req, res, next) {
+ const p = req.principal;
+ if (p.kind !== 'app') return next();
+ const decision = capabilities.check(p.claims, cap);
+ if (decision.allowed) return next();
+ return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${cap} not granted`, ctx: req.ov });
+ };
+ }
+
+ return { middleware, resolve, requireCapability: require_, requireRead };
+}
+
+module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+
+/**
+ * express.Router() whose get/post handlers may be async: a rejected promise goes to next(err) (the
+ * app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.
+ */
+const express = require('express');
+
+function asyncRouter() {
+ const r = express.Router();
+ for (const method of ['get', 'post']) {
+ const orig = r[method].bind(r);
+ r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {
+ try {
+ const out = h(req, res, next);
+ if (out && typeof out.catch === 'function') out.catch(next);
+ } catch (err) { next(err); }
+ })));
+ }
+ return r;
+}
+
+module.exports = { asyncRouter };
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+'use strict';
+
+/**
+ * OpenVibe.Inventory — process entry. `node server/index.js`
+ * Listens on PORT (5030) behind nginx (deploy/).
+ */
+const { createApp } = require('./app');
+const { gracefulStop } = require('openvibe-sdk/service');
+const { startSubscriptions } = require('openvibe-sdk/account-data');
+
+/**
+ * The process stop (openvibe-sdk/service): the HTTP drain runs, then the timers clear, the Events subscriptions and the
+ * outbox relay stop, the JWKS refresher stops and the store closes. Exported so a test can inject `exit` and
+ * `signals: false`. `extra` is what start() adds (the subscriptions).
+ */
+function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
+ return gracefulStop({
+ name: 'OpenVibe.Inventory', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
+ close: [() => { for (const t of timers) clearInterval(t); }, ...extra, () => ctx.outbox.stop(), () => ctx.keys.client.stop(), () => ctx.s.close()],
+ });
+}
+
+async function start() {
+ const { app, ctx } = await createApp();
+ const { config } = ctx;
+
+ const server = app.listen(config.port, config.host, () => {
+ console.log(`[OpenVibe.Inventory] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.s.db.store})`);
+ });
+ server.keepAliveTimeout = 65_000;
+ ctx.keys.client.start();
+ ctx.outbox.start();
+ // The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
+ // INVENTORY_EVENTS_SECRET or the client secret.
+ const subscriptions = startSubscriptions({
+ eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],
+ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ });
+ // Sent events older than a week go (openvibe-sdk outbox prune), every six hours.
+ const prune = setInterval(() => { Promise.resolve(ctx.outbox.outbox.prune()).catch(() => {}); }, 6 * 3600 * 1000);
+ prune.unref();
+
+ createLifecycle({ server, ctx, timers: [prune], extra: [() => { if (subscriptions) subscriptions.stop(); }] });
+ return { server, ctx };
+}
+
+if (require.main === module) {
+ start().catch((err) => { console.error('[OpenVibe.Inventory] failed to start:', err); process.exit(1); });
+}
+
+module.exports = { start, createLifecycle };
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+
+/**
+ * Account export and deletion → Inventory (ADR-033, ADR-054 §7; openvibe-sdk/account-data).
+ *
+ * export the person's instances (every state), what they have equipped and their ledger rows
+ * deletion their equipped set and instances are deleted (grant receipts go with the instances, ON DELETE CASCADE);
+ * their ledger rows stay without their subject or name, so supply counts and each item's history stay
+ * true; a creator's definitions stay, without the creator credit
+ *
+ * Nothing exported is a secret.
+ */
+const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+
+const TABLES = [
+ // Equipped rows reference instances, so they go first.
+ { table: 'inventory_equipped', subject: 'owner_subject', file: 'equipped.json', columns: ['kind', 'slot', 'instance_id', 'updated_at'], order: 'updated_at' },
+ { table: 'inventory_instances', subject: 'owner_subject', file: 'items.json', columns: ['id', 'definition_id', 'origin', 'state', 'attributes', 'serial', 'acquired_at'], order: 'acquired_at' },
+ { table: 'inventory_ledger', subject: 'owner_subject', file: 'history.json', columns: ['at', 'action', 'instance_id', 'definition_id', 'actor', 'reason'], order: 'id', erase: { anonymize: {} } },
+ { table: 'inventory_definitions', subject: 'credit_subject', file: null, erase: { anonymize: { credit_name: null } } },
+];
+
+/** A ledger row the person acted in (an equip they made) keeps the row and loses their name. */
+async function extraErase(t, subjects, counts) {
+ counts.add(counts.retained, 'tombstones', await t.exec("UPDATE inventory_ledger SET actor = 'user:deleted' WHERE actor = ANY($1::text[])", [subjects.map((x) => `user:${x}`)]));
+}
+
+function create({ db, log = console } = {}) {
+ return createAccountData({ db, service: 'inventory', tables: TABLES, extraErase, log });
+}
+
+module.exports = { create, TABLES, TOPICS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+'use strict';
+
+/**
+ * ADR-054 §8, convert and verify: Live's cosmetics (user_cosmetics and user_equipped in Live's database, read only) become
+ * instances and equipped slots here, for each person's Network subject. scripts/import-live.js runs these.
+ *
+ * importLive({ live, inv, db, apply }) grants each unlock as an instance with origin `migrated`, issued by
+ * service:live, keeping its unlock time, under the grant key
+ * live-migration:<user_id>:<item_id> (a second run grants nothing again);
+ * then equips each person's slots. Without apply, counts what it would do.
+ * verifyLive({ live, inv, db }) each person's unlocked set and equipped slots, row for row → differences
+ * (items no longer in Live's catalog are listed apart, as unknown)
+ *
+ * A Live account without a Network subject cannot hold items; it is counted, never guessed.
+ */
+const { LIVE } = require('./seed');
+
+const SLOT_KIND = { name_effect: 'live.name_effect', particle: 'live.particle', hat: 'live.hat', voice: 'live.voice' };
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+/** Live's rows per subject: subject → { userId, items: Map(item_id → unlocked_at), slots: Map(slot → item_id) }. */
+async function liveRows(live) {
+ const items = await live.many(`SELECT u.id AS user_id, u.subject_id, c.item_id, c.unlocked_at
+ FROM user_cosmetics c JOIN users u ON u.id = c.user_id ORDER BY u.id, c.item_id`);
+ const slots = await live.many(`SELECT u.id AS user_id, u.subject_id, e.slot, e.item_id
+ FROM user_equipped e JOIN users u ON u.id = e.user_id ORDER BY u.id, e.slot`);
+ const by = new Map();
+ const noSubject = new Set();
+ const of = (r) => {
+ if (!SUBJECT_RE.test(String(r.subject_id || ''))) { noSubject.add(Number(r.user_id)); return null; }
+ if (!by.has(r.subject_id)) by.set(r.subject_id, { userId: Number(r.user_id), items: new Map(), slots: new Map() });
+ return by.get(r.subject_id);
+ };
+ for (const r of items) { const p = of(r); if (p) p.items.set(r.item_id, r.unlocked_at); }
+ for (const r of slots) { const p = of(r); if (p) p.slots.set(r.slot, r.item_id); }
+ return { by, noSubject: noSubject.size, unlocks: items.length, equippedSlots: slots.length };
+}
+
+/** Live's ov_now() text ('2026-09-01 12:00:00', UTC) or an ISO time → ISO, else null. */
+function isoOf(v) {
+ if (!v) return null;
+ const d = new Date(String(v).includes('T') ? String(v) : `${String(v).replace(' ', 'T')}Z`);
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
+}
+
+async function importLive({ live, inv, db, apply = false, log = console }) {
+ const rows = await liveRows(live);
+ const out = { people: rows.by.size, unlocks: rows.unlocks, equippedSlots: rows.equippedSlots, noSubject: rows.noSubject, granted: 0, alreadyHere: 0, equipped: 0, unknown: [], notOwned: 0 };
+ const defs = new Map();
+ const def = async (alias) => { if (!defs.has(alias)) defs.set(alias, await inv.byAlias(LIVE, alias)); return defs.get(alias); };
+ for (const [subject, p] of rows.by) {
+ for (const [itemId, unlockedAt] of p.items) {
+ const d = await def(itemId);
+ if (!d) { out.unknown.push(itemId); continue; }
+ if (!apply) { out.granted++; continue; }
+ const r = await inv.grant(LIVE, { definition_id: d.id, subject, idempotency_key: `live-migration:${p.userId}:${itemId}`, origin: 'migrated', reason: 'moved from OpenVibe.Live', acquired_at: isoOf(unlockedAt) || undefined }, { allowMigrated: true });
+ if (r.created) out.granted++; else out.alreadyHere++;
+ }
+ for (const [slot, itemId] of p.slots) {
+ const kind = SLOT_KIND[slot];
+ const d = kind ? await def(itemId) : null;
+ if (!d) { out.unknown.push(itemId); continue; }
+ if (!apply) { out.equipped++; continue; }
+ const inst = await db.maybe("SELECT id FROM inventory_instances WHERE definition_id = $1 AND owner_subject = $2 AND state = 'owned' LIMIT 1", [d.id, subject]);
+ if (!inst) { out.notOwned++; log.warn(`[import-live] ${subject} wears ${itemId} in ${slot} without owning it in Live; left empty`); continue; }
+ await inv.equip(subject, LIVE, { kind, slot, instance_id: inst.id });
+ out.equipped++;
+ }
+ }
+ out.unknown = [...new Set(out.unknown)];
+ return out;
+}
+
+async function verifyLive({ live, inv, db }) {
+ const rows = await liveRows(live);
+ const differences = [];
+ const unknown = new Set();
+ const known = new Map();
+ const isKnown = async (alias) => { if (!known.has(alias)) known.set(alias, !!(await inv.byAlias(LIVE, alias))); return known.get(alias); };
+ for (const [subject, p] of rows.by) {
+ const mine = await db.many(`SELECT a.alias FROM inventory_instances i JOIN inventory_definition_aliases a ON a.definition_id = i.definition_id AND a.issuer = $2
+ WHERE i.owner_subject = $1 AND i.state = 'owned'`, [subject, LIVE]);
+ const have = new Set(mine.map((r) => r.alias));
+ const missing = [];
+ for (const x of p.items.keys()) {
+ if (have.has(x)) continue;
+ // An item Live no longer has in its catalog was never imported (importLive names it); it is not a difference.
+ if (await isKnown(x)) missing.push(x); else unknown.add(x);
+ }
+ const eq = (await inv.equipped([subject]))[0].slots;
+ const worn = new Map();
+ for (const [key, v] of Object.entries(eq)) {
+ const a = await db.maybe('SELECT alias FROM inventory_definition_aliases WHERE definition_id = $1 AND issuer = $2', [v.definition_id, LIVE]);
+ worn.set(key.split(':')[1], a ? a.alias : null);
+ }
+ // A slot Live has but whose item Live does not own is left empty on purpose (importLive counts it as notOwned).
+ const slots = [...p.slots].filter(([slot, item]) => p.items.has(item) && worn.get(slot) !== item).map(([slot]) => slot);
+ if (missing.length || slots.length) differences.push({ subject, missing, slots });
+ }
+ return { people: rows.by.size, unlocks: rows.unlocks, equippedSlots: rows.equippedSlots, noSubject: rows.noSubject, differences, unknown: [...unknown] };
+}
+
+module.exports = { importLive, verifyLive, liveRows, isoOf, SLOT_KIND };
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+'use strict';
+
+/**
+ * What OpenVibe.Inventory registers at boot (ADR-054 §8), idempotently:
+ * server/data/kinds.json the kinds (contracts with renderers); an existing kind is updated in place
+ * server/data/live-definitions.json Live's cosmetics catalog as definitions issued by service:live, found again by their
+ * alias (Live's item id); a definition already there is left as it is
+ * A boot that changes nothing writes nothing.
+ */
+const path = require('path');
+
+const DATA = path.join(__dirname, '..', 'data');
+const LIVE = 'service:live';
+
+async function seed(inv, { log = console } = {}) {
+ const { kinds } = require(path.join(DATA, 'kinds.json'));
+ for (const k of kinds) await inv.upsertKind(k);
+ const { definitions } = require(path.join(DATA, 'live-definitions.json'));
+ let made = 0;
+ for (const d of definitions) {
+ if (await inv.byAlias(LIVE, d.alias)) continue;
+ await inv.createDefinition(LIVE, { kind: d.kind, name: d.name, description: d.description || undefined, art: d.art, rarity: d.rarity, attributes: d.attributes, aliases: [d.alias] }, { status: 'published' });
+ made++;
+ }
+ if (made) log.log(`[OpenVibe.Inventory] seeded ${made} of Live's cosmetics as definitions`);
+ return { kinds: kinds.length, definitions: made };
+}
+
+module.exports = { seed, LIVE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,342 @@
@@ -0,0 +1,342 @@
+'use strict';
+
+/**
+ * The inventory (ADR-054): kinds, definitions, instances, the equipped set and the ledger, and the only writes to them.
+ * Every movement writes one ledger row and one event in the same transaction as the change.
+ *
+ * kinds list, get, upsert (seed only: kinds are contracts, registered at boot from server/data/kinds.json)
+ * definitions list, get, byAlias, create, update; only the kind's issuer defines items in its namespace
+ * grant an issuer gives one instance to a person: idempotent per (issuer, key), honours the supply cap; a
+ * non-stackable item the person already owns answers the instance they have
+ * consume an issuer uses up or revokes an instance it issued
+ * inventory a person's instances with their definitions (public: owned ones of published or retired items)
+ * equipped many people's equipped sets in one query; equip, clear
+ *
+ * Nothing here sells, buys, trades or converts an item (ADR-054 §5): there is no price column and no such route.
+ */
+const Ajv = require('ajv/dist/2020');
+
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const DEF_RE = /^itd_[0-9A-HJKMNP-TV-Z]{26}$/;
+const INST_RE = /^inv_[0-9A-HJKMNP-TV-Z]{26}$/;
+const RARITY = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
+
+class InventoryError extends Error {
+ constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail; }
+}
+const fail = (status, code, detail) => { throw new InventoryError(status, code, detail); };
+
+/** 'service:live' → { type: 'service', id: 'live' } (the event envelope's actor). */
+function actorOf(requester) {
+ const [type, ...rest] = String(requester).split(':');
+ return { type: type === 'user' ? 'user' : type === 'app' ? 'app' : type === 'agent' ? 'agent' : 'service', id: rest.join(':') };
+}
+
+function kindRow(r) {
+ return { id: r.id, name: r.name, description: r.description || undefined, slots: r.slots, surfaces: r.surfaces, attributes: r.attribute_schema, stackable: r.stackable, issuer: r.issuer };
+}
+
+function definitionRow(r, aliases = []) {
+ const out = {
+ id: r.id, kind: r.kind, name: r.name, art: r.art, rarity: r.rarity, attributes: r.attributes || {}, issuer: r.issuer,
+ supply: { cap: r.supply_cap == null ? null : Number(r.supply_cap), issued: Number(r.supply_issued) },
+ status: r.status, created_at: r.created_at, updated_at: r.updated_at,
+ };
+ if (r.description) out.description = r.description;
+ if (aliases.length) out.aliases = aliases;
+ if (r.published_at) out.published_at = r.published_at;
+ if (r.credit_subject || r.credit_name) out.credit = { ...(r.credit_subject ? { subject: r.credit_subject } : {}), ...(r.credit_name ? { name: r.credit_name } : {}) };
+ return out;
+}
+
+function instanceRow(r) {
+ const out = { id: r.id, definition_id: r.definition_id, owner: r.owner_subject, origin: r.origin, state: r.state, acquired_at: r.acquired_at };
+ if (r.attributes && Object.keys(r.attributes).length) out.attributes = r.attributes;
+ if (r.serial != null) out.serial = Number(r.serial);
+ return out;
+}
+
+const encodeCursor = (r) => Buffer.from(`${r.acquired_at}|${r.id}`).toString('base64url');
+function decodeCursor(c) {
+ if (!c) return null;
+ const [at, id] = Buffer.from(String(c), 'base64url').toString('utf8').split('|');
+ return at && INST_RE.test(id || '') ? { at, id } : fail(400, 'inventory.bad_cursor', 'cursor is not one this service gave');
+}
+
+function createInventory({ s, outbox, log = console }) {
+ const db = s.db;
+ const ajv = new Ajv({ strict: false, allErrors: false });
+ const validators = new Map(); // kind id + updated_at → compiled attribute schema
+
+ async function emit(event_type, { subject, visibility, actor, payload }) {
+ await outbox.emit({ event_type, version: 1, source: 'inventory', actor: actorOf(actor), subject, visibility, payload });
+ }
+
+ async function ledger(action, { owner = null, instance = null, definition = null, actor, reason = null, detail = {} }) {
+ await db.exec(`INSERT INTO inventory_ledger (at, action, owner_subject, instance_id, definition_id, actor, reason, detail)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [s.iso(), action, owner, instance, definition, actor, reason, JSON.stringify(detail)]);
+ }
+
+ // ── Kinds ─────────────────────────────────────────────
+ async function getKind(id) {
+ const r = await db.maybe('SELECT * FROM inventory_kinds WHERE id = $1', [String(id || '')]);
+ return r ? kindRow(r) : null;
+ }
+ async function listKinds() { return (await db.many('SELECT * FROM inventory_kinds ORDER BY id')).map(kindRow); }
+ async function upsertKind(k) {
+ const now = s.iso();
+ await db.exec(`INSERT INTO inventory_kinds (id, name, description, slots, surfaces, attribute_schema, stackable, issuer, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
+ ON CONFLICT (id) DO UPDATE SET name = excluded.name, description = excluded.description, slots = excluded.slots, surfaces = excluded.surfaces,
+ attribute_schema = excluded.attribute_schema, stackable = excluded.stackable, issuer = excluded.issuer, updated_at = excluded.updated_at
+ WHERE (inventory_kinds.name, inventory_kinds.description, inventory_kinds.slots, inventory_kinds.surfaces, inventory_kinds.attribute_schema, inventory_kinds.stackable, inventory_kinds.issuer)
+ IS DISTINCT FROM (excluded.name, excluded.description, excluded.slots, excluded.surfaces, excluded.attribute_schema, excluded.stackable, excluded.issuer)`,
+ [k.id, k.name, k.description || null, JSON.stringify(k.slots || []), JSON.stringify(k.surfaces || []), JSON.stringify(k.attribute_schema || { type: 'object' }), !!k.stackable, k.issuer, now]);
+ }
+ async function checkAttributes(kind, attributes) {
+ const row = await db.maybe('SELECT attribute_schema, updated_at FROM inventory_kinds WHERE id = $1', [kind]);
+ const key = `${kind}@${row.updated_at}`;
+ if (!validators.has(key)) validators.set(key, ajv.compile(row.attribute_schema));
+ const validate = validators.get(key);
+ if (!validate(attributes || {})) fail(422, 'inventory.bad_attributes', `attributes do not fit ${kind}: ${ajv.errorsText(validate.errors)}`);
+ }
+
+ // ── Definitions ───────────────────────────────────────
+ async function aliasesOf(ids) {
+ if (!ids.length) return new Map();
+ const rows = await db.many('SELECT definition_id, alias FROM inventory_definition_aliases WHERE definition_id = ANY($1::text[]) ORDER BY alias', [ids]);
+ const m = new Map();
+ for (const r of rows) { if (!m.has(r.definition_id)) m.set(r.definition_id, []); m.get(r.definition_id).push(r.alias); }
+ return m;
+ }
+ async function getDefinition(id) {
+ if (!DEF_RE.test(String(id || ''))) return null;
+ const r = await db.maybe('SELECT * FROM inventory_definitions WHERE id = $1', [id]);
+ if (!r) return null;
+ return definitionRow(r, (await aliasesOf([r.id])).get(r.id) || []);
+ }
+ async function byAlias(issuer, alias) {
+ const r = await db.maybe('SELECT definition_id FROM inventory_definition_aliases WHERE issuer = $1 AND alias = $2', [issuer, String(alias || '')]);
+ return r ? await getDefinition(r.definition_id) : null;
+ }
+ async function listDefinitions({ kind = null, issuer = null, statuses = ['published'], limit = 200 } = {}) {
+ const rows = await db.many(`SELECT * FROM inventory_definitions WHERE status = ANY($1::text[])
+ AND ($2::text IS NULL OR kind = $2) AND ($3::text IS NULL OR issuer = $3) ORDER BY kind, name LIMIT $4`,
+ [statuses, kind, issuer, Math.max(1, Math.min(500, Number(limit) || 200))]);
+ const aliases = await aliasesOf(rows.map((r) => r.id));
+ return rows.map((r) => definitionRow(r, aliases.get(r.id) || []));
+ }
+
+ function checkDefinitionBody(b, { partial = false } = {}) {
+ const has = (k) => b[k] !== undefined;
+ if (!partial || has('name')) if (typeof b.name !== 'string' || !b.name.trim() || b.name.length > 80) fail(422, 'inventory.bad_name', 'name is 1 to 80 characters');
+ if (has('description') && b.description !== null && (typeof b.description !== 'string' || b.description.length > 500)) fail(422, 'inventory.bad_description', 'description is at most 500 characters');
+ if (!partial || has('art')) {
+ const a = b.art;
+ if (!a || typeof a !== 'object' || Array.isArray(a) || !Object.keys(a).length) fail(422, 'inventory.bad_art', 'art names a Media object (media_id), a renderer token or an emoji');
+ for (const k of Object.keys(a)) if (!['media_id', 'token', 'emoji'].includes(k)) fail(422, 'inventory.bad_art', `art has no field ${k}`);
+ if (a.media_id !== undefined && !/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(a.media_id))) fail(422, 'inventory.bad_art', 'art.media_id is a med_ id');
+ if (a.token !== undefined && (typeof a.token !== 'string' || !a.token || a.token.length > 80)) fail(422, 'inventory.bad_art', 'art.token is 1 to 80 characters');
+ if (a.emoji !== undefined && (typeof a.emoji !== 'string' || a.emoji.length > 16)) fail(422, 'inventory.bad_art', 'art.emoji is at most 16 characters');
+ }
+ if (!partial || has('rarity')) if (!RARITY.includes(b.rarity)) fail(422, 'inventory.bad_rarity', `rarity is one of ${RARITY.join(', ')}`);
+ if (has('supply_cap') && b.supply_cap !== null && !(Number.isInteger(b.supply_cap) && b.supply_cap > 0)) fail(422, 'inventory.bad_supply', 'supply_cap is a positive whole number or null');
+ if (has('aliases') && (!Array.isArray(b.aliases) || b.aliases.length > 8 || b.aliases.some((x) => !/^[a-z0-9][a-z0-9_.-]{0,63}$/.test(String(x))))) fail(422, 'inventory.bad_alias', 'aliases: up to 8 lower-case ids');
+ }
+
+ /** A definition in the kind's namespace, by the kind's issuer (services and apps; creators open with the Workshop). */
+ async function createDefinition(requester, b, { status = null } = {}) {
+ if (!b || typeof b !== 'object') fail(400, 'request.bad_body', 'send inventory.definition-request@1');
+ const kind = await getKind(b.kind);
+ if (!kind) fail(422, 'inventory.unknown_kind', `no kind ${b.kind}`);
+ if (kind.issuer !== requester) fail(403, 'inventory.not_issuer', `only ${kind.issuer} defines ${kind.id} items (community items open with the Workshop, ADR-054 §6)`);
+ checkDefinitionBody(b);
+ await checkAttributes(kind.id, b.attributes || {});
+ const id = s.newId('itd');
+ const now = s.iso();
+ const st = status || (b.publish ? 'published' : 'draft');
+ return await s.tx(async () => {
+ await db.exec(`INSERT INTO inventory_definitions (id, kind, name, description, art, rarity, attributes, issuer, supply_cap, status, created_at, updated_at, published_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11, $12)`,
+ [id, kind.id, b.name.trim(), b.description || null, JSON.stringify(b.art), b.rarity, JSON.stringify(b.attributes || {}), requester, b.supply_cap || null, st, now, st === 'published' ? now : null]);
+ for (const alias of b.aliases || []) {
+ const taken = await db.maybe('SELECT definition_id FROM inventory_definition_aliases WHERE issuer = $1 AND alias = $2', [requester, alias]);
+ if (taken) fail(409, 'inventory.alias_taken', `${alias} already names ${taken.definition_id}`);
+ await db.exec('INSERT INTO inventory_definition_aliases (issuer, alias, definition_id) VALUES ($1, $2, $3)', [requester, alias, id]);
+ }
+ await ledger('defined', { definition: id, actor: requester, detail: { kind: kind.id, status: st } });
+ if (st === 'published') await emit('inventory.definition.published', { subject: { type: 'item_definition', id }, visibility: 'public', actor: requester, payload: { definition_id: id, kind: kind.id, name: b.name.trim(), rarity: b.rarity, issuer: requester } });
+ return await getDefinition(id);
+ });
+ }
+
+ /** Edit a definition its issuer owns: name, description and art any time; rarity, attributes and cap until it is published. */
+ async function updateDefinition(requester, id, b) {
+ if (!b || typeof b !== 'object') fail(400, 'request.bad_body', 'send the fields to change');
+ return await s.tx(async () => {
+ const cur = await db.maybe('SELECT * FROM inventory_definitions WHERE id = $1 FOR UPDATE', [String(id || '')]);
+ if (!cur) fail(404, 'inventory.definition_not_found', 'no such definition');
+ if (cur.issuer !== requester) fail(403, 'inventory.not_issuer', 'only its issuer edits a definition');
+ checkDefinitionBody(b, { partial: true });
+ const locked = cur.status === 'published' || cur.status === 'retired';
+ for (const k of ['rarity', 'attributes', 'supply_cap', 'kind']) if (locked && b[k] !== undefined) fail(409, 'inventory.published', `${k} cannot change once the item is published (its owners rely on it)`);
+ if (b.attributes !== undefined) await checkAttributes(cur.kind, b.attributes);
+ let status = cur.status;
+ if (b.status === 'retired') status = 'retired';
+ else if (b.publish && cur.status === 'draft') status = 'published';
+ else if (b.status !== undefined && b.status !== cur.status && b.status !== 'retired') fail(422, 'inventory.bad_status', 'status: retired, or publish: true on a draft');
+ const now = s.iso();
+ await db.exec(`UPDATE inventory_definitions SET name = $2, description = $3, art = $4, rarity = $5, attributes = $6, supply_cap = $7, status = $8,
+ updated_at = $9, published_at = COALESCE(published_at, CASE WHEN $8 = 'published' THEN $9 END) WHERE id = $1`,
+ [cur.id, b.name !== undefined ? b.name.trim() : cur.name, b.description !== undefined ? b.description : cur.description,
+ JSON.stringify(b.art !== undefined ? b.art : cur.art), b.rarity !== undefined ? b.rarity : cur.rarity,
+ JSON.stringify(b.attributes !== undefined ? b.attributes : cur.attributes), b.supply_cap !== undefined ? b.supply_cap : cur.supply_cap, status, now]);
+ await ledger(status !== cur.status ? status : 'edited', { definition: cur.id, actor: requester });
+ if (status === 'published' && cur.status !== 'published') {
+ await emit('inventory.definition.published', { subject: { type: 'item_definition', id: cur.id }, visibility: 'public', actor: requester, payload: { definition_id: cur.id, kind: cur.kind, name: b.name !== undefined ? b.name.trim() : cur.name, rarity: b.rarity || cur.rarity, issuer: cur.issuer } });
+ }
+ return await getDefinition(cur.id);
+ });
+ }
+
+ // ── Grants ────────────────────────────────────────────
+ /**
+ * One instance of a published definition the requester issued, to a person. → { instance, created }.
+ * origin 'migrated' is the migration's alone (scripts/import-live.js), never the API's.
+ */
+ async function grant(requester, b, { allowMigrated = false } = {}) {
+ if (!b || typeof b !== 'object') fail(400, 'request.bad_body', 'send inventory.grant-request@1');
+ if (!DEF_RE.test(String(b.definition_id || ''))) fail(422, 'inventory.bad_definition', 'definition_id is an itd_ id');
+ if (!SUBJECT_RE.test(String(b.subject || ''))) fail(422, 'inventory.bad_subject', 'items go to people: a usr_ subject');
+ const key = String(b.idempotency_key || '');
+ if (key.length < 8 || key.length > 128) fail(422, 'inventory.bad_key', 'idempotency_key is 8 to 128 characters');
+ const origin = b.origin || 'granted';
+ if (!['granted', 'earned'].concat(allowMigrated ? ['migrated'] : []).includes(origin)) fail(422, 'inventory.bad_origin', 'origin is granted or earned (nothing is bought or traded, ADR-054 §5)');
+ if (b.reason !== undefined && (typeof b.reason !== 'string' || b.reason.length > 200)) fail(422, 'inventory.bad_reason', 'reason is at most 200 characters');
+ return await s.tx(async () => {
+ const prior = await db.maybe('SELECT instance_id FROM inventory_grants WHERE issuer = $1 AND idempotency_key = $2', [requester, key]);
+ if (prior) return { instance: instanceRow(await db.maybe('SELECT * FROM inventory_instances WHERE id = $1', [prior.instance_id])), created: false };
+ const def = await db.maybe('SELECT * FROM inventory_definitions WHERE id = $1 FOR UPDATE', [b.definition_id]);
+ if (!def) fail(404, 'inventory.definition_not_found', 'no such definition');
+ if (def.issuer !== requester) fail(403, 'inventory.not_issuer', `only ${def.issuer} grants this item`);
+ if (def.status !== 'published') fail(409, 'inventory.not_published', `the item is ${def.status}`);
+ const kind = await getKind(def.kind);
+ if (b.attributes !== undefined) await checkAttributes(kind.id, { ...def.attributes, ...b.attributes });
+ if (!kind.stackable) {
+ const have = await db.maybe("SELECT * FROM inventory_instances WHERE definition_id = $1 AND owner_subject = $2 AND state = 'owned' LIMIT 1", [def.id, b.subject]);
+ if (have) {
+ await db.exec('INSERT INTO inventory_grants (issuer, idempotency_key, instance_id, created_at) VALUES ($1, $2, $3, $4)', [requester, key, have.id, s.iso()]);
+ return { instance: instanceRow(have), created: false };
+ }
+ }
+ if (def.supply_cap != null && Number(def.supply_issued) >= Number(def.supply_cap)) fail(409, 'inventory.supply_exhausted', `all ${def.supply_cap} have been given`);
+ const serial = def.supply_cap != null ? Number(def.supply_issued) + 1 : null;
+ const id = s.newId('inv');
+ const now = s.iso();
+ await db.exec('UPDATE inventory_definitions SET supply_issued = supply_issued + 1 WHERE id = $1', [def.id]);
+ await db.exec(`INSERT INTO inventory_instances (id, definition_id, owner_subject, origin, state, attributes, serial, acquired_at, updated_at)
+ VALUES ($1, $2, $3, $4, 'owned', $5, $6, $7, $7)`, [id, def.id, b.subject, origin, JSON.stringify(b.attributes || {}), serial, allowMigrated && b.acquired_at ? b.acquired_at : now]);
+ await db.exec('INSERT INTO inventory_grants (issuer, idempotency_key, instance_id, created_at) VALUES ($1, $2, $3, $4)', [requester, key, id, now]);
+ await ledger(origin, { owner: b.subject, instance: id, definition: def.id, actor: requester, reason: b.reason || null });
+ await emit('inventory.item.granted', { subject: { type: 'user', id: b.subject }, visibility: 'subject', actor: requester,
+ payload: { instance_id: id, definition_id: def.id, kind: def.kind, owner: b.subject, origin, issuer: requester } });
+ return { instance: instanceRow(await db.maybe('SELECT * FROM inventory_instances WHERE id = $1', [id])), created: true };
+ });
+ }
+
+ /** An issuer uses up (consume) or takes back (revoke) an instance of an item it issued. */
+ async function consume(requester, instanceId, { revoke = false, reason = null } = {}) {
+ if (!INST_RE.test(String(instanceId || ''))) fail(404, 'inventory.instance_not_found', 'no such instance');
+ return await s.tx(async () => {
+ const inst = await db.maybe('SELECT i.*, d.issuer FROM inventory_instances i JOIN inventory_definitions d ON d.id = i.definition_id WHERE i.id = $1 FOR UPDATE OF i', [instanceId]);
+ if (!inst) fail(404, 'inventory.instance_not_found', 'no such instance');
+ if (inst.issuer !== requester) fail(403, 'inventory.not_issuer', `only ${inst.issuer} consumes or revokes this item`);
+ if (inst.state !== 'owned') fail(409, 'inventory.not_owned', `the instance is ${inst.state}`);
+ const state = revoke ? 'revoked' : 'consumed';
+ await db.exec('UPDATE inventory_instances SET state = $2, updated_at = $3 WHERE id = $1', [inst.id, state, s.iso()]);
+ await db.exec('DELETE FROM inventory_equipped WHERE instance_id = $1', [inst.id]);
+ await ledger(state, { owner: inst.owner_subject, instance: inst.id, definition: inst.definition_id, actor: requester, reason });
+ const payload = revoke
+ ? { instance_id: inst.id, definition_id: inst.definition_id, owner: inst.owner_subject, reason: String(reason || 'revoked by its issuer').slice(0, 200) }
+ : { instance_id: inst.id, definition_id: inst.definition_id, owner: inst.owner_subject, issuer: requester };
+ await emit(revoke ? 'inventory.item.revoked' : 'inventory.item.consumed', { subject: { type: 'user', id: inst.owner_subject }, visibility: 'subject', actor: requester, payload });
+ return instanceRow(await db.maybe('SELECT * FROM inventory_instances WHERE id = $1', [inst.id]));
+ });
+ }
+
+ // ── Reading ───────────────────────────────────────────
+ /** inventory.inventory@1. own: every state; public: owned instances of published or retired items. */
+ async function inventory(subject, { own = false, cursor = null, limit = 100, kind = null } = {}) {
+ if (!SUBJECT_RE.test(String(subject || ''))) fail(404, 'inventory.unknown_subject', 'a usr_ subject');
+ const after = decodeCursor(cursor);
+ const n = Math.max(1, Math.min(200, Number(limit) || 100));
+ const rows = await db.many(`SELECT i.* FROM inventory_instances i JOIN inventory_definitions d ON d.id = i.definition_id
+ WHERE i.owner_subject = $1 AND ($2::boolean OR (i.state = 'owned' AND d.status IN ('published', 'retired')))
+ AND ($3::text IS NULL OR d.kind = $3)
+ AND ($4::text IS NULL OR (i.acquired_at, i.id) < ($4, $5))
+ ORDER BY i.acquired_at DESC, i.id DESC LIMIT $6`, [subject, own, kind, after ? after.at : null, after ? after.id : null, n + 1]);
+ const page = rows.slice(0, n);
+ const defIds = [...new Set(page.map((r) => r.definition_id))];
+ const defs = defIds.length ? await db.many('SELECT * FROM inventory_definitions WHERE id = ANY($1::text[])', [defIds]) : [];
+ const aliases = await aliasesOf(defIds);
+ return {
+ subject,
+ instances: page.map(instanceRow),
+ definitions: Object.fromEntries(defs.map((d) => [d.id, definitionRow(d, aliases.get(d.id) || [])])),
+ next_cursor: rows.length > n ? encodeCursor(page[page.length - 1]) : null,
+ };
+ }
+
+ /** Many people's equipped sets (inventory.equipped@1 each), in one query. Unknown or empty subjects get {}. */
+ async function equipped(subjects) {
+ const list = [...new Set((subjects || []).map(String))].filter((x) => SUBJECT_RE.test(x)).slice(0, 100);
+ if (!list.length) return [];
+ const rows = await db.many(`SELECT e.owner_subject, e.kind, e.slot, e.instance_id, e.updated_at, i.definition_id, d.art->>'token' AS token
+ FROM inventory_equipped e JOIN inventory_instances i ON i.id = e.instance_id JOIN inventory_definitions d ON d.id = i.definition_id
+ WHERE e.owner_subject = ANY($1::text[]) AND i.state = 'owned' ORDER BY e.owner_subject, e.kind, e.slot`, [list]);
+ const by = new Map(list.map((x) => [x, { subject: x, slots: {}, updated_at: null }]));
+ for (const r of rows) {
+ const e = by.get(r.owner_subject);
+ e.slots[`${r.kind}:${r.slot}`] = { instance_id: r.instance_id, definition_id: r.definition_id, ...(r.token ? { token: r.token } : {}) };
+ if (!e.updated_at || r.updated_at > e.updated_at) e.updated_at = r.updated_at;
+ }
+ const fallback = s.iso();
+ return [...by.values()].map((e) => ({ ...e, updated_at: e.updated_at || fallback }));
+ }
+
+ /** Equip an owned instance in a slot of its kind, or clear the slot (instance_id null). → inventory.equipped@1. */
+ async function equip(subject, actor, b) {
+ if (!SUBJECT_RE.test(String(subject || ''))) fail(403, 'inventory.no_subject', 'equipping is for a person');
+ if (!b || typeof b !== 'object') fail(400, 'request.bad_body', 'send inventory.equip-request@1');
+ const kind = await getKind(b.kind);
+ if (!kind) fail(422, 'inventory.unknown_kind', `no kind ${b.kind}`);
+ if (!kind.slots.includes(b.slot)) fail(422, 'inventory.bad_slot', `${kind.id} has the slots ${kind.slots.join(', ') || '(none: not equippable)'}`);
+ await s.tx(async () => {
+ if (b.instance_id === null) {
+ const cur = await db.maybe('DELETE FROM inventory_equipped WHERE owner_subject = $1 AND kind = $2 AND slot = $3 RETURNING instance_id', [subject, kind.id, b.slot]);
+ if (cur) {
+ await ledger('unequipped', { owner: subject, instance: cur.instance_id, actor, detail: { kind: kind.id, slot: b.slot } });
+ await emit('inventory.item.unequipped', { subject: { type: 'user', id: subject }, visibility: 'public', actor, payload: { kind: kind.id, slot: b.slot, owner: subject } });
+ }
+ return;
+ }
+ const inst = await db.maybe(`SELECT i.*, d.kind FROM inventory_instances i JOIN inventory_definitions d ON d.id = i.definition_id
+ WHERE i.id = $1 AND i.owner_subject = $2 AND i.state = 'owned'`, [String(b.instance_id || ''), subject]);
+ if (!inst) fail(409, 'inventory.not_owned', 'you do not own that item (or it was used up)');
+ if (inst.kind !== kind.id) fail(422, 'inventory.wrong_kind', `that item is a ${inst.kind}, not a ${kind.id}`);
+ const now = s.iso();
+ await db.exec(`INSERT INTO inventory_equipped (owner_subject, kind, slot, instance_id, updated_at) VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (owner_subject, kind, slot) DO UPDATE SET instance_id = excluded.instance_id, updated_at = excluded.updated_at`, [subject, kind.id, b.slot, inst.id, now]);
+ await ledger('equipped', { owner: subject, instance: inst.id, definition: inst.definition_id, actor, detail: { kind: kind.id, slot: b.slot } });
+ await emit('inventory.item.equipped', { subject: { type: 'user', id: subject }, visibility: 'public', actor, payload: { instance_id: inst.id, definition_id: inst.definition_id, kind: kind.id, slot: b.slot, owner: subject } });
+ });
+ return (await equipped([subject]))[0];
+ }
+
+ return {
+ listKinds, getKind, upsertKind, listDefinitions, getDefinition, byAlias, createDefinition, updateDefinition,
+ grant, consume, inventory, equipped, equip, ledger,
+ };
+}
+
+module.exports = { createInventory, InventoryError, actorOf, SUBJECT_RE, DEF_RE, INST_RE, RARITY };
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+
+/**
+ * Truthful readiness for GET /api/ready (openvibe-shared/ready, Track O).
+ *
+ * db required a real round trip to OpenVibe.Inventory's PostgreSQL store
+ * network_jwks optional the Network signing key is loaded; without it nobody can sign in or call the API
+ * oauth_client optional OV_OAUTH_CLIENT_SECRET is set (sign-in needs it)
+ * valkey optional shared per-caller limit counters
+ *
+ * The product adds its own required checks here (a queue, a model provider, a worker) with a real round trip,
+ * so /api/ready never reads green for something that is not working.
+ */
+const { jwksStatus } = require('openvibe-sdk/auth');
+const { createReadiness } = require('openvibe-shared/ready');
+
+function createServiceReadiness({ s, config, valkey = null, release = null }) {
+ return createReadiness({
+ service: 'inventory',
+ release,
+ checks: [
+ {
+ name: 'db', required: true,
+ check: async () => {
+ const r = await s.db.ready();
+ return r.ok ? { ok: true, detail: r.detail } : r.error;
+ },
+ },
+ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-caller limits count in this process only' }) },
+ {
+ name: 'network_jwks', required: false,
+ check: () => {
+ const statuses = jwksStatus();
+ if (!statuses.length) return 'no JWKS clients (misconfigured)';
+ const first = statuses[0];
+ if (!first.ready) return 'Network signing key not loaded yet: sign-in and the API are unavailable';
+ return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };
+ },
+ },
+ { name: 'oauth_client', required: false, check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete') },
+ ],
+ });
+}
+
+module.exports = { createServiceReadiness };
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+
+/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+/** Tagged template: interpolations are escaped unless wrapped in raw(). Arrays are joined. */
+class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }
+const raw = (s) => new Raw(s);
+function html(strings, ...values) {
+ let out = strings[0];
+ values.forEach((v, i) => {
+ out += render(v) + strings[i + 1];
+ });
+ return raw(out);
+}
+function render(v) {
+ if (v == null || v === false) return '';
+ if (v instanceof Raw) return v.s;
+ if (Array.isArray(v)) return v.map(render).join('');
+ return esc(v);
+}
+
+const code = (s) => html`<code>${s}</code>`;
+const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
+
+/** A <table> from headers and rows of cells (cells are already html or plain values). */
+function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {
+ if (!rows.length) return html`<p class="muted">${empty}</p>`;
+ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;
+}
+
+/** A problem as returned by Network, Events or Media — shown as it came, never softened. */
+function problemBox(p, { title = 'The request failed' } = {}) {
+ if (!p) return '';
+ return html`<div class="problem" role="alert"><strong>${title}</strong>
+<p>${p.detail || 'No detail was given.'}</p>
+<p class="muted small">HTTP ${p.status || '—'} · <code>${p.code || 'unknown'}</code>${p.requestId ? html` · request <code>${p.requestId}</code>` : ''}</p></div>`;
+}
+
+const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;
+const csrfField = (token) => html`<input type="hidden" name="csrf" value="${token}">`;
+const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
+
+module.exports = { esc, html, raw, Raw, render, code, time, table, problemBox, notice, csrfField, badge };
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
+'use strict';
+
+/**
+ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript:
+ * - <head>: title, description, canonical, robots (pages are noindex unless they say otherwise), Open Graph/Twitter,
+ * JSON-LD (openvibe-shared/seo), plus this service's extras: the shared app icon, the stylesheet, the boost marker
+ * - the OpenVibe Frame: theme-loader, web-runtime, navbar and footer scripts (the shell boots the
+ * navbar; the footer is initialised below), a <noscript> navigation bar and the server-rendered footer
+ * - the product's own script only where a page offers an in-browser convenience
+ */
+const crypto = require('crypto');
+const ovServe = require('openvibe-shared/serve');
+const fs = require('fs');
+const path = require('path');
+const appIcon = require('openvibe-shared/app-icon');
+const frame = require('openvibe-shared/frame');
+const shell = require('openvibe-shared/shell');
+const cache = require('openvibe-shared/cache-policy');
+const { html, raw, esc } = require('./html');
+
+const NETWORK_URL = 'https://openvibe.network';
+const SITE_NAME = 'OpenVibe.Inventory';
+const TAGLINE = 'Your items, on every OpenVibe site.';
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
+// The product's own navigation: one entry per public page it serves.
+const NAV = [
+ { label: 'Every item', href: '/items' },
+ { label: 'Your inventory', href: '/me' },
+ { label: 'What shipped', href: '/updates' },
+];
+
+const hashes = new Map();
+function assetVersion(rel) {
+ if (hashes.has(rel)) return hashes.get(rel);
+ let v = 'dev';
+ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
+ hashes.set(rel, v);
+ return v;
+}
+const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
+
+// The deployed release (app.js sets it from openvibe-shared/release): openvibe-shared/boost swaps a page in place only
+// between pages of the same release, and does a normal load across a deploy.
+let RELEASE = 'dev';
+function setRelease(id) { if (id) RELEASE = String(id); }
+
+/**
+ * o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],
+ * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names]
+ */
+function renderPage(o) {
+ const viewer = o.viewer || { kind: 'anonymous' };
+ const signedIn = viewer.kind === 'user';
+ const pathNow = o.path || '/';
+ const loginNext = encodeURIComponent(pathNow);
+ const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
+ const nav = {
+ service: 'inventory',
+ apiBase: NETWORK_URL,
+ links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
+ history: { type: 'page', title: o.title || SITE_NAME },
+ silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
+ sessionUrl: '/auth/me',
+ loginUrl: '/auth/login?next={path}', // filled from the current page (boost moves between pages)
+ logoutUrl: '/auth/logout?next={path}', // Sign out in the shared navbar ends this site's session too
+ notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)
+ };
+ const footer = { service: 'inventory', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
+ const account = signedIn
+ ? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
+ : html`<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
+ const crumbs = (o.crumbs || []).length
+ ? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`
+ : '';
+ const scripts = (o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`);
+ return shell.page({
+ name: SITE_NAME,
+ lang: 'en',
+ title: o.title || `${SITE_NAME}: ${TAGLINE}`,
+ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',
+ siteName: SITE_NAME,
+ description: o.description || `${SITE_NAME} — ${TAGLINE}`,
+ canonical,
+ robots: o.index ? 'index, follow' : 'noindex, nofollow',
+ jsonLd: (o.jsonLd || []).filter(Boolean),
+ home: '/',
+ navLinks: NAV,
+ navbar: nav,
+ footer,
+ head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
+${appIcon.headTags({ site: 'inventory' })}
+<link rel="stylesheet" href="${asset('css/app.css')}">
+${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
+${render(scripts)}
+<meta name="ov-boost" content="inventory@${esc(RELEASE)}">
+<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
+ body: `<a class="skip" href="#main">Skip to content</a>
+<div id="navbar-mount"></div>
+<noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>
+<main id="main" class="page">
+${render(crumbs)}
+${render(o.body)}
+${o.path === '/' ? frame.shipped({ service: 'inventory', title: `Recently shipped on ${SITE_NAME}` }) : ''}
+</main>
+<script>
+window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
+document.addEventListener('DOMContentLoaded', function () {
+ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }
+});
+</script>`,
+ });
+}
+
+function render(v) { return require('./html').render(v); }
+
+/** Send a page; a page rendered for a signed-in person is private and never cached. */
+function send(res, status, o) {
+ // A page rendered for a signed-in person names them in the account bar: never shared caches.
+ const personal = o.viewer && o.viewer.kind === 'user';
+ if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? o.cache : cache.htmlHeaders({ private: true }));
+ res.set('Vary', 'Cookie');
+ res.status(status).type('html').send(renderPage(o));
+}
+
+module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL, NAV, raw, html };
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+'use strict';
+
+/**
+ * A deliberately small Markdown renderer for text a caller supplied or a model wrote. Everything is escaped first; only
+ * headings, paragraphs, lists, fenced code, tables, bold, italics, inline code and links are recognised. Links are kept
+ * only when http(s) or relative, and never vouched for: rel="nofollow ugc noopener", opened in a new tab.
+ */
+const { esc } = require('./html');
+
+function inline(s) {
+ let out = esc(s);
+ out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
+ out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
+ out = out.replace(/(^|[\s(])\*([^*\s][^*]*)\*(?=[\s).,;:!?]|$)/g, '$1<em>$2</em>');
+ out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, text, href) => {
+ const h = href.replace(/&/g, '&');
+ if (/^https?:\/\//.test(h)) return `<a href="${esc(h)}" rel="nofollow ugc noopener" target="_blank">${text}</a>`;
+ if (/^[#./a-zA-Z0-9]/.test(h) && !/^[a-z]+:/i.test(h)) return `<a href="${esc(h)}">${text}</a>`;
+ return text;
+ });
+ // Bare links the model wrote out, outside an <a> already made above.
+ out = out.replace(/(^|[\s(])(https?:\/\/[^\s<)"']+[^\s<)"'.,;:!?])/g, (m, pre, url) => `${pre}<a href="${url}" rel="nofollow ugc noopener" target="_blank">${url}</a>`);
+ return out;
+}
+
+const cells = (line) => line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
+const isSeparator = (line) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line);
+
+function markdown(src, { headingOffset = 1 } = {}) {
+ const lines = String(src || '').replace(/\r\n/g, '\n').split('\n');
+ const out = [];
+ let para = [];
+ let list = null; // { type: 'ul' | 'ol', items: [] }
+ let fence = null;
+ const flushPara = () => { if (para.length) { out.push(`<p>${inline(para.join(' '))}</p>`); para = []; } };
+ const flushList = () => { if (list) { out.push(`<${list.type}>${list.items.map((i) => `<li>${inline(i)}</li>`).join('')}</${list.type}>`); list = null; } };
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ if (fence) {
+ if (/^```/.test(line)) { out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(line);
+ continue;
+ }
+ if (/^```/.test(line)) { flushPara(); flushList(); fence = []; continue; }
+ // A table: a header row, a separator row, then rows while lines start with |.
+ if (/^\s*\|/.test(line) && i + 1 < lines.length && isSeparator(lines[i + 1])) {
+ flushPara(); flushList();
+ const head = cells(line);
+ const rows = [];
+ i += 2;
+ while (i < lines.length && /^\s*\|/.test(lines[i])) { rows.push(cells(lines[i])); i++; }
+ i--;
+ out.push(`<div class="table-wrap"><table><thead><tr>${head.map((h) => `<th scope="col">${inline(h)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`);
+ continue;
+ }
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
+ if (h) {
+ flushPara(); flushList();
+ const level = Math.min(6, h[1].length + headingOffset);
+ out.push(`<h${level}>${inline(h[2])}</h${level}>`);
+ continue;
+ }
+ const li = line.match(/^\s*(?:[-*]|(\d+)\.)\s+(.*)$/);
+ if (li) {
+ flushPara();
+ const type = li[1] ? 'ol' : 'ul';
+ if (!list || list.type !== type) { flushList(); list = { type, items: [] }; }
+ list.items.push(li[2]);
+ continue;
+ }
+ if (!line.trim()) { flushPara(); flushList(); continue; }
+ if (list && /^\s{2,}\S/.test(line)) { list.items[list.items.length - 1] += ` ${line.trim()}`; continue; }
+ flushList();
+ para.push(line.trim());
+ }
+ if (fence) out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`);
+ flushPara(); flushList();
+ return out.join('\n');
+}
+
+module.exports = { markdown, inline };
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+/**
+ * ADR-033 / ADR-054 §7: Inventory's part of an account export and of an account deletion, through the signed loopback
+ * route POST /internal/events with a stand-in Network. Alice's items, equipped set and history are exported; her
+ * deletion removes her items and equipped set, keeps the ledger rows without her, keeps the supply count true, leaves
+ * Bob's items alone and confirms once.
+ */
+const assert = require('assert');
+const http = require('http');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const { boot, check, done } = require('./helpers/boot');
+
+const SECRET = `whsec_${'fixture'.repeat(6)}`;
+
+async function startNetworkStub() {
+ const calls = [];
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_inventory', token_type: 'Bearer', expires_in: 300 });
+ calls.push({ url: req.url, auth: req.headers.authorization, body: JSON.parse(Buffer.concat(chunks).toString() || 'null') });
+ return json(201, {});
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };
+}
+
+(async () => {
+ const stub = await startNetworkStub();
+ const t = await boot({ env: { INVENTORY_EVENTS_SECRET: SECRET }, accountSend: createNetworkSender({ networkInternalUrl: stub.url, clientId: 'inventory', clientSecret: 'inventory-secret' }) });
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const db = t.ctx.s.db;
+ const count = async (sql, args) => Number(await db.value(sql, args));
+ const deliver = async (event, { secret = SECRET, headers = {} } = {}) => {
+ const body = JSON.stringify({ event, seq: 1 });
+ const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } });
+ return { status: res.status, json: await res.json().catch(() => null) };
+ };
+ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), payload });
+ const inv = t.ctx.inv;
+ let crown;
+
+ try {
+ await check('alice and bob own items; alice wears one', async () => {
+ crown = await inv.byAlias('service:live', 'hat_crown');
+ const rainbow = await inv.byAlias('service:live', 'fx_rainbow');
+ for (const [who, key] of [[alice, 'a1'], [bob, 'b1']]) await inv.grant('service:live', { definition_id: crown.id, subject: who.subject, idempotency_key: `test-0000-${key}` });
+ const r = await inv.grant('service:live', { definition_id: rainbow.id, subject: alice.subject, idempotency_key: 'test-0000-a2' });
+ await inv.equip(alice.subject, `user:${alice.subject}`, { kind: 'live.name_effect', slot: 'name_effect', instance_id: r.instance.id });
+ });
+
+ await check('the export carries her items, equipped set and history, and nothing of bob\'s', async () => {
+ const r = await deliver(ev('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX1', subject: alice.subject }));
+ assert.deepStrictEqual([r.status, r.json && r.json.outcome], [200, 'exported'], JSON.stringify(r.json));
+ const part = stub.calls.find((c) => c.url === '/internal/account-exports/exp_01JZ0000000000000000000EX1/parts');
+ assert.strictEqual(part.auth, 'Bearer tok_inventory');
+ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['equipped.json', 'history.json', 'items.json']);
+ assert.strictEqual(part.body.files.find((f) => f.name === 'items.json').content.length, 2);
+ assert.ok(!JSON.stringify(part.body).includes(bob.subject));
+ });
+
+ await check('the deletion removes her items and equipped set, keeps the ledger without her, and confirms once', async () => {
+ const event = ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DE1', subject: alice.subject });
+ const r = await deliver(event);
+ assert.deepStrictEqual([r.status, r.json && r.json.outcome], [200, 'erased'], JSON.stringify(r.json));
+ assert.strictEqual(await count('SELECT count(*) FROM inventory_instances WHERE owner_subject = $1', [alice.subject]), 0);
+ assert.strictEqual(await count('SELECT count(*) FROM inventory_equipped WHERE owner_subject = $1', [alice.subject]), 0);
+ assert.strictEqual(await count('SELECT count(*) FROM inventory_ledger WHERE owner_subject = $1 OR actor = $2', [alice.subject, `user:${alice.subject}`]), 0);
+ assert.ok(await count('SELECT count(*) FROM inventory_ledger WHERE owner_subject IS NULL') >= 3, 'her history stays, without her');
+ assert.strictEqual(await count('SELECT count(*) FROM inventory_instances WHERE owner_subject = $1', [bob.subject]), 1, 'bob keeps his');
+ assert.strictEqual(await count('SELECT supply_issued FROM inventory_definitions WHERE id = $1', [crown.id]), 2, 'the supply count stays true');
+ const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE1/confirmations');
+ assert.strictEqual(conf.length, 1);
+ assert.deepStrictEqual([conf[0].body.erased.inventory_instances, conf[0].body.erased.inventory_equipped], [2, 1]);
+ assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');
+ assert.strictEqual(stub.calls.filter((c) => c.url.includes('/confirmations')).length, 1);
+ });
+
+ await check('the route refuses a bad signature and a request that came through a proxy', async () => {
+ const event = ev('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX2', subject: alice.subject });
+ assert.strictEqual((await deliver(event, { secret: `whsec_${'mismatch'.repeat(5)}` })).status, 401);
+ assert.strictEqual((await deliver(event, { headers: { 'X-Forwarded-For': '203.0.113.9' } })).status, 403);
+ });
+ } finally {
+ await t.close();
+ await stub.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+/**
+ * The one cache rule the service serves assets with (openvibe-shared/cache-policy, plan T11): a URL
+ * whose ?v= is the asset's current hash is immutable for a year, because those bytes can never
+ * change under the same URL; a wrong or missing ?v= may change at any moment, so it gets the
+ * short public window with a day of stale-while-revalidate. The values are pinned here against
+ * the module, not re-stated: the service never picks its own numbers.
+ */
+const assert = require('assert');
+const cache = require('openvibe-shared/cache-policy');
+const { assetVersion } = require('../server/render/layout');
+const { boot, check, done } = require('./helpers/boot');
+
+const ASSET = 'css/app.css';
+const SHORT = 'public, max-age=300, stale-while-revalidate=86400';
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('the current ?v=<assetVersion> is immutable for a year', async () => {
+ const r = await t.get(`/${ASSET}?v=${assetVersion(ASSET)}`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=31536000, immutable');
+ });
+
+ await check('a wrong-but-hex ?v= is never pinned', async () => {
+ const r = await t.get(`/${ASSET}?v=deadbeefdeadbeef`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), SHORT);
+ });
+
+ await check('no ?v= gets the short public window', async () => {
+ const r = await t.get(`/${ASSET}`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), SHORT);
+ });
+
+ await check('those values are the module\'s, not the service\'s own', () => {
+ assert.strictEqual(cache.assetHeaders(ASSET, { hashed: true }), 'public, max-age=31536000, immutable');
+ assert.strictEqual(cache.assetHeaders(ASSET, { hashed: false }), SHORT);
+ assert.strictEqual(cache.htmlHeaders({ private: true }), 'private, no-store');
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
+'use strict';
+/**
+ * The Network signing key is fetched and verified through openvibe-sdk/auth (plan T0/T1). The
+ * SDK keeps one client per URL: the last good keys through outages, exponential backoff, a
+ * rotation honoured at once, unknown-kid floods throttled. The service is a thin shim around it:
+ * the boot reads the key and verifies a session/app token against the URL,
+ * a JWKS outage with cached keys still verifies,
+ * readiness (network_jwks) shows the cache state.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const { boot, check, done } = require('./helpers/boot');
+
+function jwksServer({ doc = null, fail = null } = {}) {
+ const seen = [];
+ const srv = http.createServer((req, res) => {
+ seen.push(req.url);
+ if (fail) { res.writeHead(503); return res.end('down'); }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(doc));
+ });
+ return new Promise((resolve) => {
+ srv.listen(0, '127.0.0.1', () => resolve({
+ url: `http://127.0.0.1:${srv.address().port}`,
+ close: () => new Promise((r) => srv.close(r)),
+ seen,
+ setDoc: (d) => { doc = d; },
+ setFail: (f) => { fail = f; },
+ }));
+ });
+}
+
+function signJwt(claims, privateKey, { kid = null } = {}) {
+ const header = { alg: 'RS256', typ: 'JWT', ...(kid ? { kid } : {}) };
+ const input = `${Buffer.from(JSON.stringify(header)).toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}`;
+ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
+}
+
+function pem(k) { return k.export({ type: 'spki', format: 'pem' }); }
+function jwk(k, kid) { return { ...k.export({ format: 'jwk' }), use: 'sig', alg: 'RS256', kid }; }
+
+(async () => {
+ await check('a token signed by a key from a stub JWKS verifies', async () => {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const jwks = await jwksServer({ doc: { keys: [jwk(publicKey, 'k1')] } });
+ const { createKeyStore, jwksUrl } = require('../server/auth/keys');
+ const config = { networkInternalUrl: jwks.url, networkUrl: jwks.url, networkIssuer: 'https://openvibe.network' };
+ const keys = createKeyStore({ config, log: console });
+ const url = jwksUrl(config);
+ await keys.ensure();
+ const now = Math.floor(Date.now() / 1000);
+ const token = signJwt({ iss: config.networkIssuer, sub: 'usr_demo', username: 'demo', aud: ['openvibe.network'], iat: now, exp: now + 300 }, privateKey, { kid: 'k1' });
+ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: 'openvibe.network' });
+ assert.strictEqual(v.ok, true, `verify should succeed: ${v.reason || ''}`);
+ assert.strictEqual(v.claims.username, 'demo');
+ assert.ok(keys.get(), 'the cached PEM is exposed to the contracts guard');
+ assert.deepStrictEqual(jwks.seen, ['/api/.well-known/jwks']);
+ await jwks.close();
+ });
+
+ // A rotation publishes two keys: verification must use the one the token names, not the first.
+ await check('service tokens get the key their kid names (a rotation keeps two keys)', async () => {
+ const a = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const b = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const jwks = await jwksServer({ doc: { keys: [jwk(a.publicKey, 'old'), jwk(b.publicKey, 'new')] } });
+ const { createKeyStore } = require('../server/auth/keys');
+ const config = { networkInternalUrl: jwks.url, networkUrl: jwks.url, networkIssuer: 'https://openvibe.network' };
+ const keys = createKeyStore({ config, log: { warn() {}, info() {} } });
+ const now = Math.floor(Date.now() / 1000);
+ const claims = { iss: config.networkIssuer, sub: 'app:x', aud: ['inventory.openvibe.network'], iat: now, exp: now + 300 };
+ assert.strictEqual(await keys.pemForToken(signJwt(claims, b.privateKey, { kid: 'new' })), pem(b.publicKey), 'the new key for a token signed with it');
+ assert.strictEqual(await keys.pemForToken(signJwt(claims, a.privateKey, { kid: 'old' })), pem(a.publicKey));
+ assert.strictEqual(await keys.pemForToken(signJwt(claims, a.privateKey)), pem(a.publicKey), 'no kid: the first key');
+ assert.strictEqual(await keys.pemForToken('garbage'), pem(a.publicKey), 'an undecodable header falls back to the first key (the guard refuses the token)');
+ await jwks.close();
+ });
+
+ await check('with keys cached, a JWKS outage still verifies (the last good keys)', async () => {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const jwks = await jwksServer({ doc: { keys: [jwk(publicKey, 'k1')] } });
+ const { createKeyStore } = require('../server/auth/keys');
+ const config = { networkInternalUrl: jwks.url, networkUrl: jwks.url, networkIssuer: 'https://openvibe.network' };
+ const keys = createKeyStore({ config, log: console });
+ await keys.ensure();
+ const before = jwks.seen.length;
+ jwks.setFail(true); // Network goes away
+ const now = Math.floor(Date.now() / 1000);
+ const token = signJwt({ iss: config.networkIssuer, sub: 'usr_demo', username: 'demo', aud: ['openvibe.network'], iat: now, exp: now + 300 }, privateKey, { kid: 'k1' });
+ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: 'openvibe.network' });
+ assert.strictEqual(v.ok, true, `outage should not break verification: ${v.reason || ''}`);
+ assert.strictEqual(jwks.seen.length, before, 'no new fetch during the outage');
+ await jwks.close();
+ });
+
+ await check('readiness (network_jwks) shows the SDK JWKS state', async () => {
+ const t = await boot();
+ const r = await t.get('/api/ready');
+ const b = r.json();
+ assert.strictEqual(b.checks.network_jwks.status, 'ok', `readiness JWKS not ok: ${JSON.stringify(b.checks.network_jwks)}`);
+ assert.ok(b.checks.network_jwks.detail && b.checks.network_jwks.detail.keys >= 1, `detail.keys should be >= 1: ${JSON.stringify(b.checks.network_jwks.detail)}`);
+ assert.strictEqual(b.checks.network_jwks.detail.failures, 0);
+ await t.close();
+ });
+
+ await check('an unreachable JWKS never names the URL or the fetch error (503, /api/ready)', async () => {
+ // Port 9 (discard) refuses instantly: the SDK's message is "JWKS http://127.0.0.1:9/…: connect ECONNREFUSED".
+ const { createKeyStore } = require('../server/auth/keys');
+ const config = { networkInternalUrl: 'http://127.0.0.1:9', networkUrl: 'http://127.0.0.1:9', networkIssuer: 'https://openvibe.network' };
+ const keys = createKeyStore({ config, log: { warn() {}, log() {}, info() {}, error() {} } });
+ await keys.ensure();
+ const now = Math.floor(Date.now() / 1000);
+ const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const token = signJwt({ iss: config.networkIssuer, sub: 'usr_demo', aud: ['openvibe.network'], iat: now, exp: now + 300 }, privateKey);
+ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: 'openvibe.network' });
+ assert.strictEqual(v.ok, false);
+ assert.strictEqual(v.code, 'token.no_key', `the failure is token.no_key: ${v.code}`);
+ assert.strictEqual(v.reason, 'signing key not loaded yet', 'a fixed public reason, not the SDK text');
+ assert.ok(!/127\.0\.0\.1|jwks|ECONNREFUSED|fetch failed/i.test(v.reason), `reason leaks: ${v.reason}`);
+
+ // The same unreachable JWKS behind a booted service: neither sign-in nor /api/ready names it.
+ const t = await boot({ env: { OV_NETWORK_INTERNAL_URL: 'http://127.0.0.1:9', OV_NETWORK_URL: 'http://127.0.0.1:9' } });
+ const ready = await t.get('/api/ready');
+ const readyText = ready.text;
+ assert.ok(!/127\.0\.0\.1|\.well-known\/jwks|ECONNREFUSED|fetch failed/i.test(readyText),
+ `/api/ready leaks the JWKS URL or the fetch error: ${readyText.slice(0, 400)}`);
+ const me = await t.get('/auth/me', { cookie: `inventory_at=${token}` });
+ assert.strictEqual(me.status, 401, `an unverifiable session token is 401: ${me.status} ${me.text.slice(0, 200)}`);
+ assert.ok(!/127\.0\.0\.1|\.well-known\/jwks|ECONNREFUSED|fetch failed/i.test(me.text),
+ `the sign-in answer leaks the JWKS URL or the fetch error: ${me.text.slice(0, 300)}`);
+ await t.close();
+ });
+
+ await check('an expired session token starts no session (the SDK takes the clock as a number)', async () => {
+ const t = await boot();
+ const u = t.network.addUser('expired-demo');
+ // Signed by the Network, but exp is an hour in the past: the SDK's Math.floor(now / 1000) must see it.
+ const expired = t.network.userToken(u, { ttl: -3600 });
+ const me = await t.get('/auth/me', { cookie: `inventory_at=${expired}` });
+ assert.strictEqual(me.status, 401, `an expired cookie is not a session: ${me.status} ${me.text.slice(0, 200)}`);
+ const page = await t.get('/', { cookie: `inventory_at=${expired}` });
+ assert.ok(page.status < 400, `a page still renders: ${page.status}`);
+ assert.ok(!page.text.includes('expired-demo'), 'the expired token signed nobody in');
+ await t.close();
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
+'use strict';
+/**
+ * Sign-in with PKCE S256 (client id inventory, cookies inventory_at/inventory_rt/inventory_oauth), truthful readiness
+ * (the database is required; JWKS, the OAuth client and Valkey are optional), /release.json, loopback-only
+ * /metrics, and pages useful without JavaScript.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const user = t.network.addUser('kim');
+
+ await check('login redirects to Network with state and an S256 code challenge', async () => {
+ const r = await t.get('/auth/login?next=/updates');
+ assert.strictEqual(r.status, 302);
+ const loc = new URL(r.headers.get('location'));
+ assert.strictEqual(loc.origin + loc.pathname, `${t.network.url}/oauth/authorize`);
+ assert.strictEqual(loc.searchParams.get('client_id'), 'inventory');
+ assert.strictEqual(loc.searchParams.get('code_challenge_method'), 'S256');
+ assert.match(loc.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);
+ assert.match(loc.searchParams.get('state'), /^[0-9a-f]{32}$/);
+ assert.match(r.headers.get('set-cookie'), /inventory_oauth=.*Path=\/auth.*HttpOnly/i);
+ });
+
+ await check('callback exchanges the code with the matching verifier and sets httpOnly session cookies', async () => {
+ const login = await t.get('/auth/login?next=/updates');
+ const loc = new URL(login.headers.get('location'));
+ const flowCookie = login.headers.get('set-cookie').split(';')[0];
+ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
+ const r = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });
+ assert.strictEqual(r.status, 302, r.text);
+ assert.strictEqual(r.headers.get('location'), '/updates');
+ const set = r.headers.get('set-cookie');
+ assert.match(set, /inventory_at=[^;]+;.*HttpOnly/i);
+ assert.match(set, /inventory_rt=[^;]+;.*HttpOnly/i);
+ const at = set.match(/inventory_at=([^;]+)/)[1];
+ const me = await t.get('/auth/me', { cookie: `inventory_at=${at}` });
+ assert.strictEqual(me.json().user.username, 'kim');
+ assert.ok(!me.text.includes(at), '/auth/me never returns the token');
+ });
+
+ await check('a wrong state or a verifier that does not match is refused', async () => {
+ const login = await t.get('/auth/login');
+ const loc = new URL(login.headers.get('location'));
+ const flowCookie = login.headers.get('set-cookie').split(';')[0];
+ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
+ const bad = await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: flowCookie });
+ assert.strictEqual(bad.status, 400);
+ const other = t.network.issueCode(user, crypto.createHash('sha256').update('another-verifier').digest('base64url'));
+ const mismatch = await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });
+ assert.strictEqual(mismatch.status, 400, 'Network refuses the PKCE check; the service reports it');
+ const noCookie = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`);
+ assert.strictEqual(noCookie.status, 400);
+ });
+
+ await check('next= only accepts same-site paths', async () => {
+ const r = await t.get('/auth/login?next=//evil.example/x');
+ const flow = decodeURIComponent(r.headers.get('set-cookie').match(/inventory_oauth=([^;]+)/)[1]);
+ assert.strictEqual(JSON.parse(flow).next, '/');
+ });
+
+ await check('readiness is truthful: db required; JWKS, the OAuth client and Valkey optional', async () => {
+ const r = await t.get('/api/ready');
+ const b = r.json();
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(b.checks.db.status, 'ok');
+ assert.strictEqual(b.checks.db.required, true);
+ assert.strictEqual(b.checks.network_jwks.status, 'ok');
+ assert.strictEqual(b.checks.network_jwks.required, false);
+ assert.strictEqual(b.checks.oauth_client.status, 'ok');
+ assert.strictEqual(b.checks.oauth_client.required, false);
+ assert.ok('valkey' in b.checks, 'the optional Valkey check is listed');
+ });
+
+ await check('/release.json and /metrics (loopback only)', async () => {
+ const rel = await t.get('/release.json');
+ assert.strictEqual(rel.status, 200);
+ assert.strictEqual(rel.json().service, 'inventory');
+ assert.deepStrictEqual(require('openvibe-contracts').validate('registry.release-manifest@1', rel.json()).errors, []);
+ assert.strictEqual(rel.json().metrics_url, '/release-metrics');
+ const m = await t.get('/metrics');
+ assert.strictEqual(m.status, 200);
+ assert.match(m.text, /http_requests_total|release_info/);
+ const proxied = await t.get('/metrics', { headers: { 'x-forwarded-for': '203.0.113.9' } });
+ assert.notStrictEqual(proxied.status, 200, 'a proxied request is not loopback');
+ });
+
+ await check('public pages are complete without JavaScript (noscript nav, SSR footer)', async () => {
+ for (const p of ['/', '/updates']) {
+ const r = await t.get(p);
+ assert.strictEqual(r.status, 200, p);
+ assert.match(r.text, /<noscript><nav/);
+ assert.match(r.text, /<footer id="ov-footer"/);
+ assert.match(r.text, /<main id="main"/);
+ }
+ });
+
+ await check('the API answers /ping and unknown routes are problem+json 404', async () => {
+ const ping = await t.get('/api/v1/ping');
+ assert.strictEqual(ping.status, 200);
+ assert.deepStrictEqual(ping.json(), { ok: true, service: 'inventory' });
+ const gone = await t.get('/api/v1/nope');
+ assert.strictEqual(gone.status, 404);
+ assert.match(gone.headers.get('content-type'), /application\/problem\+json/);
+ assert.strictEqual(gone.json().code, 'route.not_found');
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+/**
+ * Per-caller rate limits (server/http/caller-limits.js): past its limit one caller gets 429 problem+json
+ * `rate_limited` with Retry-After, before the route does any work, while another caller still passes; the
+ * window reopens on the clock. A person counts as themselves, anyone else by address. Reads take
+ * INVENTORY_LIMITS_MINUTE/INVENTORY_LIMITS_HOUR; the product's expensive routes get their own numbers in BUDGETS.
+ * Health, ready, release.json and metrics are never limited; refusals are logged (no token) and counted in
+ * inventory_rate_limited_total.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { caller, BUDGETS } = require('../server/http/caller-limits');
+
+const READ = 'inventory.item.read';
+
+(async () => {
+ // The limiter's clock: 15 s into a minute, so the minute window has 45 s left. Reads: 3 a minute.
+ let clock = Date.UTC(2026, 8, 27, 12, 0, 15);
+ const t = await boot({ callerLimits: true, limitsNow: () => clock, env: { INVENTORY_LIMITS_MINUTE: '3', INVENTORY_LIMITS_HOUR: '100' } });
+ const rosa = t.network.addUser('rosa');
+ const sam = t.network.addUser('sam');
+
+ await check('the product\'s writes have their own numbers: equipping, granting, defining', () => {
+ assert.deepStrictEqual(Object.keys(BUDGETS).sort(), ['inventory.define', 'inventory.equip', 'inventory.grant']);
+ Object.values(BUDGETS).forEach((b) => assert.ok(b.minute > 0 && b.hour >= b.minute, JSON.stringify(b)));
+ });
+
+ await check('a read: 3 a minute per address, then 429 rate_limited with Retry-After; another address passes', async () => {
+ const from = (ip) => ({ headers: { 'x-forwarded-for': ip } });
+ for (let i = 0; i < 3; i++) assert.strictEqual((await t.get('/api/v1/ping', from('203.0.113.7'))).status, 200);
+ const r = await t.get('/api/v1/ping', from('203.0.113.7'));
+ assert.strictEqual(r.status, 429);
+ assert.match(r.headers.get('content-type'), /application\/problem\+json/);
+ assert.ok(Number(r.headers.get('retry-after')) > 0 && Number(r.headers.get('retry-after')) <= 45, r.headers.get('retry-after'));
+ const body = r.json();
+ assert.strictEqual(body.code, 'rate_limited');
+ assert.ok(body.detail.includes(READ), body.detail);
+ assert.strictEqual((await t.get('/api/v1/ping', from('203.0.113.8'))).status, 200, 'another address still passes');
+ });
+
+ await check('a signed-in person counts as themselves, not by address', async () => {
+ for (let i = 0; i < 3; i++) assert.strictEqual((await t.get('/api/v1/ping', { as: rosa, headers: { 'x-forwarded-for': '203.0.113.7' } })).status, 200, 'rosa is not the address that was refused');
+ const r = await t.get('/api/v1/ping', { as: rosa, headers: { 'x-forwarded-for': '203.0.113.9' } });
+ assert.strictEqual(r.status, 429, 'rosa is over her own limit from any address');
+ assert.strictEqual((await t.get('/api/v1/ping', { as: sam, headers: { 'x-forwarded-for': '203.0.113.9' } })).status, 200, 'another person still passes');
+ });
+
+ await check('the next minute opens the window again', async () => {
+ clock += 60_000;
+ assert.strictEqual((await t.get('/api/v1/ping', { as: rosa })).status, 200);
+ });
+
+ await check('a budget that was never declared cannot be taken', () => {
+ assert.throws(() => t.ctx.limits.budget('inventory.thing.create'), /no budget named/);
+ });
+
+ await check('health, ready, release.json and metrics are never limited', async () => {
+ for (let i = 0; i < 6; i++) {
+ assert.strictEqual((await t.get('/api/health')).status, 200);
+ assert.notStrictEqual((await t.get('/api/ready')).status, 429);
+ assert.strictEqual((await t.get('/release.json')).status, 200);
+ assert.strictEqual((await t.get('/metrics')).status, 200);
+ }
+ });
+
+ await check('refusals are counted in inventory_rate_limited_total and logged without a token', async () => {
+ const m = (await t.get('/metrics')).text;
+ const counted = m.split('\n').filter((l) => l.includes('inventory_rate_limited_total')).join('\n');
+ assert.ok(/inventory_rate_limited_total\{limit="inventory\.item\.read",window="minute"\} 2/.test(m), counted);
+ const logs = t.logs();
+ assert.ok(logs.includes(`[Limits] ${READ}: user:${rosa.subject} refused`), 'one log line per refusal');
+ assert.ok(logs.includes(`[Limits] ${READ}: ip:203.0.113.7 refused`), 'the address refusal is logged');
+ assert.ok(!/\[Limits\][^\n]*eyJ/.test(logs), 'a token in the log');
+ });
+
+ await check('who is counted', () => {
+ assert.strictEqual(caller({ principal: { requester: 'app:app_x' }, viewer: { kind: 'user', subject: 'usr_a' }, ip: '203.0.113.1' }), 'app:app_x', 'a principal wins');
+ assert.strictEqual(caller({ viewer: { kind: 'user', subject: 'usr_a' }, ip: '203.0.113.1' }), 'user:usr_a');
+ assert.strictEqual(caller({ viewer: { kind: 'anonymous' }, ip: '198.51.100.4' }), 'ip:198.51.100.4');
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+/**
+ * The public crawl artifacts (plan T11), fetched from the booted app: /robots.txt, /sitemap.xml,
+ * /llms.txt, /llms-full.txt and the home page's JSON-LD. Each is served with the right status and
+ * content type, carries at least one real entry, lists public pages only, and takes lastmod from the
+ * site's own data (STATUS.json) — never from the clock.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./helpers/boot');
+
+const STATUS_UPDATED = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'STATUS.json'), 'utf8')).updated;
+// llms.txt names the public machine endpoints on purpose, so only sign-in and the API are private there.
+const PRIVATE = ['/auth/', '/api/'];
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('robots.txt: 200 text/plain, sign-in and the API disallowed, the public pages crawlable, sitemap named', async () => {
+ const r = await t.get('/robots.txt');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /^text\/plain/);
+ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
+ for (const d of ['/auth/', '/api/']) assert.ok(r.text.includes(`Disallow: ${d}`), `missing Disallow: ${d}`);
+ for (const d of ['/updates']) assert.ok(!r.text.includes(`Disallow: ${d}`), `${d} must stay crawlable`);
+ assert.ok(r.text.includes('Sitemap: https://inventory.openvibe.network/sitemap.xml'), 'sitemap not named');
+ assert.ok(/^User-agent: \*$/m.test(r.text), 'no User-agent: * group');
+ });
+
+ await check('sitemap.xml: 200 application/xml, real entries, lastmod from the data', async () => {
+ const r = await t.get('/sitemap.xml');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /^application\/xml/);
+ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
+ for (const p of ['/', '/updates']) assert.ok(r.text.includes(`<loc>https://inventory.openvibe.network${p}</loc>`), `no ${p} entry`);
+ for (const p of ['/auth/login', '/api/v1/ping']) assert.ok(!r.text.includes(`<loc>https://inventory.openvibe.network${p}</loc>`), `${p} is not a public page`);
+ const lastmods = [...r.text.matchAll(/<lastmod>([^<]+)<\/lastmod>/g)].map((m) => m[1]);
+ assert.ok(lastmods.length > 0, 'no lastmod anywhere');
+ assert.ok(lastmods.every((d) => /^\d{4}-\d{2}-\d{2}$/.test(d)), `bad lastmod: ${lastmods.slice(0, 3)}`);
+ assert.ok(lastmods.every((d) => d === STATUS_UPDATED), `lastmod must come from STATUS.json (${STATUS_UPDATED}), got ${[...new Set(lastmods)].join(', ')}`);
+ for (const p of PRIVATE) assert.ok(!r.text.includes(`https://inventory.openvibe.network${p}`), `private path in sitemap: ${p}`);
+ });
+
+ await check('llms.txt: 200 text/plain, real links, nothing private', async () => {
+ const r = await t.get('/llms.txt');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /^text\/plain/);
+ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
+ assert.ok(r.text.startsWith('# OpenVibe.Inventory'), 'no title');
+ assert.ok(r.text.includes('(https://inventory.openvibe.network/updates)'), 'no updates link');
+ assert.ok(r.text.includes('(https://openvibe.services)'), 'no pointer to the developer platform');
+ assert.ok(r.text.includes('https://inventory.openvibe.network/sitemap.xml'), 'sitemap not listed');
+ assert.ok(r.text.includes('(https://inventory.openvibe.network/llms-full.txt)'), 'llms-full.txt not listed');
+ for (const p of ['/auth/']) assert.ok(!r.text.includes(`https://inventory.openvibe.network${p}`), `private path in llms.txt: ${p}`);
+ });
+
+ await check('llms-full.txt: 200 text/plain, the site title, and every page the sitemap lists', async () => {
+ const r = await t.get('/llms-full.txt');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /^text\/plain/);
+ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
+ assert.ok(r.text.startsWith('# OpenVibe.Inventory'), 'no title');
+ const sitemap = await t.get('/sitemap.xml');
+ const locs = [...sitemap.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
+ assert.ok(locs.length >= 2, `sitemap too small: ${locs.length}`);
+ for (const loc of locs) assert.ok(r.text.includes(loc), `llms-full.txt is missing ${loc}`);
+ assert.ok(r.text.includes('URL: https://inventory.openvibe.network/updates\n'), 'no /updates entry');
+ for (const p of PRIVATE) assert.ok(!r.text.includes(`https://inventory.openvibe.network${p}`), `private path in llms-full.txt: ${p}`);
+ });
+
+ await check('home page: WebSite + the site\'s primary type, the same for every crawler', async () => {
+ const r = await t.get('/');
+ assert.strictEqual(r.status, 200);
+ const blocks = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+ const types = blocks.map((b) => b['@type']);
+ assert.ok(types.includes('WebSite'), `no WebSite node: ${types}`);
+ assert.ok(types.includes('WebApplication'), `no primary-type node: ${types}`);
+ const site = blocks.find((b) => b['@type'] === 'WebSite');
+ assert.strictEqual(site.url, 'https://inventory.openvibe.network');
+ assert.strictEqual(site.name, 'OpenVibe.Inventory');
+ const page = blocks.find((b) => b['@type'] === 'WebPage');
+ assert.strictEqual(page.url, 'https://inventory.openvibe.network/');
+ });
+ } finally { await t.close(); }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+// Many requests per test file: the per-address limits would answer 429 part-way. The per-caller limits
+// (server/http/caller-limits.js) count nobody unless a test asks: boot({ callerLimits: true, limitsNow }).
+process.env.INVENTORY_RATE_LIMIT_PER_MIN = process.env.INVENTORY_RATE_LIMIT_PER_MIN || '100000';
+process.env.INVENTORY_API_RATE_LIMIT_PER_MIN = process.env.INVENTORY_API_RATE_LIMIT_PER_MIN || '100000';
+/**
+ * Boots OpenVibe.Inventory on a temp database with a mock Network (test/helpers/mocks.js), captures every log line, and
+ * returns a small HTTP client. Every test file gets its own.
+ *
+ * const t = await boot();
+ * t.get(path, { as: user, bearer, json, form, headers, method, cookie })
+ * t.signIn(user) → cookie header value (inventory_at = a Network user token, as after /auth/callback)
+ * t.logs(), t.dbDump()
+ */
+const http = require('http');
+const { startNetwork } = require('./mocks');
+
+const captured = [];
+for (const m of ['log', 'info', 'warn', 'error']) {
+ const orig = console[m].bind(console);
+ console[m] = (...a) => { captured.push(a.map(String).join(' ')); if (process.env.VERBOSE) orig(...a); };
+}
+
+async function boot(opts = {}) {
+ const network = await startNetwork(opts.network || {});
+ const env = {
+ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://inventory.openvibe.network', TRUST_PROXY: '1',
+ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
+ OV_OAUTH_CLIENT_ID: 'inventory', OV_OAUTH_CLIENT_SECRET: 'inventory-secret', COOKIE_SECURE: 'false',
+ ...(opts.env || {}),
+ };
+ for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];
+ const configLib = require('../../server/config');
+ const { createApp } = require('../../server/app');
+ const log = { log: (...a) => captured.push(a.map(String).join(' ')), warn: (...a) => captured.push(a.map(String).join(' ')), error: (...a) => captured.push(a.map(String).join(' ')), info: (...a) => captured.push(a.map(String).join(' ')) };
+
+ const config = configLib.load(env);
+ const { createStore } = require('../../server/db');
+ const testdb = await require('./db').testDb();
+ const store = createStore(testdb.db, { now: opts.now });
+ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend });
+ await built.ctx.keys.ensure();
+ const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+
+ const signIn = (user) => `inventory_at=${network.userToken(user)}`;
+
+ async function get(p, o = {}) {
+ const headers = { ...(o.headers || {}) };
+ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');
+ if (o.cookie) headers.cookie = o.cookie;
+ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;
+ let body = o.body;
+ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
+ if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
+ const res = await fetch(base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });
+ const buf = Buffer.from(await res.arrayBuffer());
+ const text = buf.toString('utf8');
+ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };
+ }
+
+ async function dbDump() {
+ const db = built.ctx.s.db;
+ const tables = (await db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()")).map((r) => r.name);
+ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.many(`SELECT * FROM "${tname}"`))))).join('\n');
+ }
+
+ const t = {
+ base, network, config, ctx: built.ctx, get, signIn, dbDump,
+ logs: () => captured.join('\n'),
+ async close() {
+ await new Promise((r) => server.close(r));
+ built.ctx.keys.client.stop();
+ await testdb.close();
+ await network.close();
+ },
+ };
+ return t;
+}
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }
+}
+function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }
+
+module.exports = { boot, check, done };
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+'use strict';
+/**
+ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
+ * INVENTORY_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.
+ */
+const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');
+const { MIGRATIONS } = require('../../server/db');
+
+const testDb = ({ store = process.env.INVENTORY_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'inventory', max });
+
+module.exports = { testDb, pgAvailable };
new file mode 100644
new file mode 100644
@@ -0,0 +1,334 @@
@@ -0,0 +1,334 @@
+'use strict';
+/**
+ * An in-process stand-in for OpenVibe.Network (sign-in, JWKS, tokens), with a real RS256 key pair. Much of it served
+ * the developer console that moved to OpenVibe.Services; what a service's tests use is sign-in and the JWKS.
+ *
+ * Network JWKS; /oauth/token (authorization_code + PKCE S256 for client `inventory`, refresh_token,
+ * client_credentials for `inventory` and for developer apps with Network's app rules:
+ * hashed secrets, sandbox audiences, cap = approved grants ∩ allowance for the audience);
+ * /oauth/revoke; /.well-known/openvibe; /api/v1/registry/services; and /api/v1/projects…
+ * with the same shapes, roles and problem codes as OpenVibe.Network's developer API
+ * (simplified: no audit paging, no rate limits). setDown(true) makes it refuse
+ * connections-equivalent (503) and drop() closes the socket.
+ * GET /api/v1/projects/:project/usage (owner/admin or staff; days 1-90, env all|sandbox|production)
+ * answers what setUsage() stored for the project, or an empty network.project-usage-result@1.
+ * POST /api/v1/projects/:project/export-tokens as Network mints them (owner/admin only,
+ * sub app:app_<project ULID>, read-only caps, purpose export), recorded in exportTokens.
+ * Every request is recorded in `.requests` so tests can assert what was (not) called.
+ */
+const http = require('http');
+const crypto = require('crypto');
+const { serviceAuth, ids, capabilities } = require('openvibe-contracts');
+
+function listen(handler) {
+ return new Promise((resolve) => {
+ const sockets = new Set();
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks);
+ const json = (status, obj, headers = {}) => { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); };
+ Promise.resolve(handler(req, raw, json, res)).catch((err) => { json(500, { error: String(err && err.message) }); });
+ });
+ });
+ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
+ server.listen(0, '127.0.0.1', () => resolve({
+ server, url: `http://127.0.0.1:${server.address().port}`,
+ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),
+ }));
+ });
+}
+
+const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
+function signJwt(claims, privateKey) {
+ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}`;
+ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
+}
+const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
+const RANK = { viewer: 1, developer: 2, admin: 3, owner: 4 };
+
+async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.media'] } = {}) {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
+ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
+ const st = {
+ users: new Map(), byUsername: new Map(), projects: new Map(), members: new Map(), apps: new Map(), creds: new Map(),
+ grants: new Map(), quotas: new Map(), usage: new Map(), audit: [], codes: new Map(), refresh: new Map(),
+ catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,
+ };
+ let issuer = null;
+ let nextUserId = 1;
+
+ const problem = (json, status, code, detail) => json(status, { type: `https://openvibe.network/problems/${code}`, title: 'Error', status, code, detail, error: detail, request_id: `req_${crypto.randomBytes(6).toString('hex')}` }, { 'Content-Type': 'application/problem+json' });
+
+ function addUser(username, { role = 'user' } = {}) {
+ const u = { id: nextUserId++, subject: ids.newId('user'), username, display_name: username, role };
+ st.users.set(u.id, u); st.byUsername.set(username, u);
+ return u;
+ }
+ function userToken(u, { ttl = 3600, iat = null } = {}) {
+ const now = iat != null ? iat : Math.floor(Date.now() / 1000);
+ return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);
+ }
+ function userFrom(req) {
+ const h = String(req.headers.authorization || '');
+ if (!h.startsWith('Bearer ')) return null;
+ const parts = h.slice(7).split('.');
+ if (parts.length !== 3) return null;
+ const ok = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicPem, Buffer.from(parts[2], 'base64url'));
+ if (!ok) return null;
+ const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
+ if (c.exp * 1000 < Date.now()) return 'expired';
+ return st.users.get(c.sub) || null;
+ }
+
+ // ── Projects model ──
+ const roleOf = (p, u) => (st.members.get(p.id) || new Map()).get(u.subject) || null;
+ const projectView = (p, role) => ({
+ id: p.id, name: p.name, owner: { type: 'user', id: p.owner }, role: role || null, environment_policy: p.environment_policy,
+ environments: p.environment_policy === 'sandbox' ? ['sandbox'] : ['sandbox', 'production'], allowance: [...p.allowance],
+ created_at: p.created_at, archived_at: p.archived_at, counts: { members: (st.members.get(p.id) || new Map()).size, apps: [...st.apps.values()].filter((a) => a.project_id === p.id && !a.revoked_at).length },
+ });
+ const appView = (a) => ({
+ id: a.id, subject: { type: 'app', id: a.id }, project_id: a.project_id, name: a.name, environment: a.environment, client_id: a.id,
+ client_type: a.client_type, redirect_uris: a.redirect_uris, created_at: a.created_at, revoked_at: a.revoked_at,
+ grants: [...st.grants.values()].filter((g) => g.app_id === a.id && g.status === 'approved').map((g) => g.capability).sort(),
+ });
+ function newCredential(app) {
+ const secret = `ovsec_${crypto.randomBytes(32).toString('base64url')}`;
+ const c = { id: ids.newId('project').replace(/^prj_/, 'crd_'), app_id: app.id, hash: sha256(secret), hint: secret.slice(-4), created_at: new Date().toISOString(), expires_at: null, revoked_at: null };
+ st.creds.set(c.id, c);
+ return { c, secret };
+ }
+ const credState = (c) => (c.revoked_at ? 'revoked' : (c.expires_at ? (Date.parse(c.expires_at) < Date.now() ? 'expired' : 'expiring') : 'active'));
+ const grantable = (id) => { const c = capabilities.get(id); return c && c.status === 'active' && ['public', 'partner'].includes(c.visibility); };
+ const defaultCatalog = () => capabilities.manifests.filter((c) => grantable(c.id)).map((c) => ({ id: c.id, owner: c.owner, audience: `openvibe.${c.owner}`, visibility: c.visibility, description: c.description || '', resourceConstraints: c.resourceConstraints, quotaClass: c.quotaClass }));
+
+ async function projectsApi(req, raw, json, u, parts, q) {
+ let body = {};
+ if (raw.length) { try { body = JSON.parse(raw.toString('utf8')); } catch { return problem(json, 400, 'request.malformed_json', 'body is not valid JSON'); } }
+ const m = req.method;
+ if (parts.length === 1 && parts[0] === 'catalog' && m === 'GET') return json(200, { capabilities: st.catalog || defaultCatalog(), rule: 'only active public capabilities' });
+ if (!parts.length && m === 'GET') {
+ const list = [...st.projects.values()].filter((p) => roleOf(p, u) || (q.get('all') === '1' && u.role === 'admin')).map((p) => projectView(p, roleOf(p, u)));
+ return json(200, { projects: list });
+ }
+ if (!parts.length && m === 'POST') {
+ const name = String(body.name || '').trim();
+ if (!name || name.length > 80) return problem(json, 422, 'project.invalid', 'name must be 1-80 characters');
+ const p = { id: ids.newId('project'), name, owner: u.subject, environment_policy: 'sandbox', allowance: [], created_at: new Date().toISOString(), archived_at: null };
+ st.projects.set(p.id, p); st.members.set(p.id, new Map([[u.subject, 'owner']]));
+ return json(201, projectView(p, 'owner'));
+ }
+ const p = st.projects.get(parts[0]);
+ const role = p ? roleOf(p, u) : null;
+ const staff = u.role === 'admin';
+ if (!p || (!role && !staff)) return problem(json, 404, 'project.not_found', 'no such project');
+ const need = (r) => { if (!role || RANK[role] < RANK[r]) { problem(json, 403, 'project.forbidden', `requires ${r} role`); return false; } return true; };
+ const rest = parts.slice(1);
+ if (!rest.length && m === 'GET') return json(200, projectView(p, role));
+ if (rest[0] === 'archive' && m === 'POST') { if (role !== 'owner' && !staff) return problem(json, 403, 'project.forbidden', 'owner only'); p.archived_at = new Date().toISOString(); for (const a of st.apps.values()) if (a.project_id === p.id) a.revoked_at = a.revoked_at || p.archived_at; return json(200, projectView(p, role)); }
+ if (rest[0] === 'members') {
+ const mem = st.members.get(p.id);
+ if (rest.length === 1 && m === 'GET') return json(200, { members: [...mem.entries()].map(([s, r]) => { const x = [...st.users.values()].find((y) => y.subject === s); return { subject: { type: 'user', id: s }, username: x && x.username, display_name: x && x.display_name, role: r, added_at: p.created_at }; }) });
+ if (rest.length === 1 && m === 'POST') {
+ if (!need('admin')) return;
+ const target = st.byUsername.get(String(body.username || ''));
+ if (!target) return problem(json, 404, 'member.user_not_found', 'no such account');
+ if (!['admin', 'developer', 'viewer'].includes(body.role)) return problem(json, 422, 'member.invalid', 'role is one of admin, developer, viewer');
+ if (body.role === 'admin' && role !== 'owner') return problem(json, 403, 'member.forbidden', `a ${role} cannot assign admin`);
+ if (mem.has(target.subject)) return problem(json, 409, 'member.exists', 'already a member');
+ mem.set(target.subject, body.role);
+ return json(201, { subject: { type: 'user', id: target.subject }, role: body.role });
+ }
+ const s = rest[1];
+ if (!mem.has(s)) return problem(json, 404, 'member.not_found', 'not a member');
+ if (m === 'PATCH') { if (!need('admin')) return; mem.set(s, body.role); return json(200, { role: body.role }); }
+ if (m === 'DELETE') { if (s !== u.subject && !need('admin')) return; if (mem.get(s) === 'owner') return problem(json, 403, 'member.forbidden', 'the owner cannot be removed'); mem.delete(s); res204(json); return; }
+ }
+ if (rest[0] === 'quotas' && m === 'GET') return json(200, { quotas: [...st.quotas.values()].filter((x) => x.project_id === p.id).map((x) => ({ capability: x.capability, limit: x.limit, window: x.window, unit: x.unit, enforced_by: `openvibe.${capabilities.get(x.capability).owner}`, updated_at: x.updated_at })), note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' });
+ if (rest[0] === 'usage' && m === 'GET') {
+ if ((!role || RANK[role] < RANK.admin) && !staff) return problem(json, 403, 'project.forbidden', 'requires admin role');
+ const days = q.get('days') || '30';
+ const env = q.get('env') || 'all';
+ if (!/^\d{1,3}$/.test(days) || Number(days) < 1 || Number(days) > 90) return problem(json, 422, 'usage.invalid', 'days is a whole number from 1 to 90');
+ if (!['all', 'sandbox', 'production'].includes(env)) return problem(json, 422, 'usage.invalid', 'env is all, sandbox or production');
+ const today = new Date().toISOString().slice(0, 10);
+ const range = { days: Number(days), from: new Date(Date.now() - (Number(days) - 1) * 86400000).toISOString().slice(0, 10), to: today };
+ const u = st.usage.get(p.id);
+ if (u) return json(200, { ...u, project_id: p.id, env, range });
+ return json(200, { project_id: p.id, env, range, generated_at: new Date().toISOString(), last_recorded_at: null, freshness: 'No usage has been recorded for this project yet.', totals: [], daily: [], quotas: [], errors: { total: 0, by_code: [], recent: [] } });
+ }
+ if (rest[0] === 'audit' && m === 'GET') { if (!role || RANK[role] < RANK.admin) { if (!staff) return problem(json, 403, 'project.forbidden', 'requires admin role'); } return json(200, { entries: st.audit.filter((e) => e.project_id === p.id).slice().reverse(), next_before: null }); }
+ if (rest[0] === 'apps') {
+ if (rest.length === 1 && m === 'GET') return json(200, { apps: [...st.apps.values()].filter((a) => a.project_id === p.id).map(appView) });
+ if (rest.length === 1 && m === 'POST') {
+ const env = String(body.environment || 'sandbox');
+ if (!['sandbox', 'production'].includes(env)) return problem(json, 422, 'app.invalid', 'environment is sandbox or production');
+ if (!need(env === 'production' ? 'admin' : 'developer')) return;
+ if (env === 'production' && p.environment_policy === 'sandbox') return problem(json, 403, 'app.environment_not_allowed', 'this project may only have sandbox apps (staff enable production)');
+ const type = String(body.type || 'confidential');
+ const redirects = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
+ for (const x of redirects) { try { const url = new URL(x); if (url.protocol !== 'https:' && url.hostname !== 'localhost') return problem(json, 422, 'app.invalid', 'redirect URIs must be https (http only for loopback)'); } catch { return problem(json, 422, 'app.invalid', `bad redirect URI ${x}`); } }
+ if (type === 'public' && !redirects.length) return problem(json, 422, 'app.invalid', 'a public app needs at least one redirect URI');
+ const a = { id: ids.newId('app'), project_id: p.id, name: String(body.name || '').trim() || 'app', environment: env, client_type: type, redirect_uris: redirects, created_at: new Date().toISOString(), revoked_at: null };
+ st.apps.set(a.id, a);
+ const out = appView(a);
+ if (type === 'confidential') { const { c, secret } = newCredential(a); out.credential = { id: c.id, client_secret: secret, hint: c.hint, shown_once: true }; }
+ st.audit.push({ id: st.audit.length + 1, project_id: p.id, at: new Date().toISOString(), actor: `user:${u.subject}`, action: 'app.created', target: `app:${a.id}`, detail: { name: a.name }, event_type: 'network.app.created' });
+ return json(201, out);
+ }
+ const a = st.apps.get(rest[1]);
+ if (!a || a.project_id !== p.id) return problem(json, 404, 'app.not_found', 'no such app');
+ const manage = a.environment === 'production' ? 'admin' : 'developer';
+ if (rest.length === 2 && m === 'GET') return json(200, appView(a));
+ if (rest.length === 2 && m === 'PATCH') { if (!need(manage)) return; if (Array.isArray(body.redirect_uris)) a.redirect_uris = body.redirect_uris; return json(200, appView(a)); }
+ if (rest.length === 2 && m === 'DELETE') { if (!staff && !need(manage)) return; a.revoked_at = new Date().toISOString(); return json(200, appView(a)); }
+ if (rest[2] === 'credentials') {
+ if (rest.length === 3 && m === 'GET') return json(200, { credentials: [...st.creds.values()].filter((c) => c.app_id === a.id).map((c) => ({ id: c.id, hint: c.hint, state: credState(c), created_at: c.created_at, expires_at: c.expires_at, revoked_at: c.revoked_at, last_used_at: null })) });
+ if (rest[3] === 'rotate' && m === 'POST') {
+ if (!need(manage)) return;
+ if (a.revoked_at) return problem(json, 409, 'app.revoked', 'app is revoked');
+ if (a.client_type !== 'confidential') return problem(json, 409, 'credential.public_client', 'public apps have no client secret');
+ const overlap = body.overlap_seconds === undefined ? 86400 : body.overlap_seconds;
+ const until = new Date(Date.now() + overlap * 1000).toISOString();
+ const prev = [];
+ for (const c of st.creds.values()) if (c.app_id === a.id && !c.revoked_at) { c.expires_at = until; prev.push({ id: c.id, expires_at: until }); }
+ const { c, secret } = newCredential(a);
+ return json(201, { credential: { id: c.id, client_secret: secret, hint: c.hint, shown_once: true }, previous: prev });
+ }
+ if (rest[4] === 'revoke' && m === 'POST') { if (!need(manage)) return; const c = st.creds.get(rest[3]); if (!c || c.app_id !== a.id) return problem(json, 404, 'credential.not_found', 'no such credential'); c.revoked_at = new Date().toISOString(); return json(200, { id: c.id, state: 'revoked' }); }
+ }
+ if (rest[2] === 'grants') {
+ const key = (cap) => `${a.id} ${cap}`;
+ const gv = (g) => ({ app_id: g.app_id, capability: g.capability, audience: g.audience, status: g.status, requested_by: g.requested_by, requested_at: g.requested_at, decided_by: g.decided_by || null, decided_at: g.decided_at || null });
+ if (rest.length === 3 && m === 'GET') return json(200, { grants: [...st.grants.values()].filter((g) => g.app_id === a.id).map(gv) });
+ if (rest.length === 3 && m === 'POST') {
+ if (!need('developer')) return;
+ const cap = String(body.capability || '');
+ const c = capabilities.get(cap);
+ if (!c) return problem(json, 422, 'grant.unknown_capability', `no capability ${cap} in the catalog`);
+ if (!grantable(cap)) return problem(json, 403, 'grant.not_grantable', `${cap} is ${c.visibility}; only public capabilities are granted to apps`);
+ const auto = RANK[role] >= RANK.admin && p.allowance.includes(cap);
+ const g = { app_id: a.id, capability: cap, audience: `openvibe.${c.owner}`, status: auto ? 'approved' : 'requested', requested_by: `user:${u.subject}`, requested_at: new Date().toISOString(), decided_at: auto ? new Date().toISOString() : null };
+ st.grants.set(key(cap), g);
+ return json(201, gv(g));
+ }
+ const g = st.grants.get(key(rest[3]));
+ if (!g) return problem(json, 404, 'grant.not_found', 'no such grant');
+ if (m === 'DELETE') { if (!staff && !need('admin')) return; g.status = 'revoked'; return json(200, gv(g)); }
+ if (rest[4] === 'approve') { if (!need('admin')) return; if (!p.allowance.includes(g.capability)) return problem(json, 403, 'grant.beyond_allowance', `${g.capability} is not in this project's allowance (staff set it)`); g.status = 'approved'; g.decided_at = new Date().toISOString(); return json(200, gv(g)); }
+ if (rest[4] === 'deny') { if (!need('admin')) return; g.status = 'denied'; return json(200, gv(g)); }
+ }
+ }
+ if (rest[0] === 'export-tokens' && rest.length === 1 && m === 'POST') {
+ if (!role || RANK[role] < RANK.admin) return problem(json, 403, 'project.forbidden', 'requires admin role');
+ const cap = { 'openvibe.media': ['media.object.list', 'media.object.read'], 'openvibe.events': ['events.app.read'] }[body.audience];
+ if (!cap) return problem(json, 422, 'export.invalid_audience', 'audience is one of openvibe.media, openvibe.events');
+ if (!['sandbox', 'production'].includes(body.env)) return problem(json, 422, 'export.invalid_env', 'env is one of sandbox, production');
+ const now = Math.floor(Date.now() / 1000);
+ const claims = {
+ iss: issuer, sub: `app:app_${p.id.replace(/^prj_/, '')}`, actor_type: 'app', aud: [body.audience], cap, ns: [p.id, `app.${p.id}.*`],
+ project_id: p.id, env: body.env, on_behalf_of: u.subject, purpose: 'export', iat: now, exp: now + st.exportTtl, jti: `tok_${crypto.randomBytes(8).toString('hex')}`,
+ };
+ const token = serviceAuth.signServiceToken(claims, privatePem);
+ st.exportTokens.push({ project_id: p.id, audience: body.audience, env: body.env, subject: u.subject, jti: claims.jti, token });
+ st.audit.push({ id: st.audit.length + 1, project_id: p.id, at: new Date().toISOString(), actor: `user:${u.subject}`, action: 'project.export_token_issued', target: claims.sub, detail: { audience: body.audience, env: body.env, cap, jti: claims.jti } });
+ return json(201, { access_token: token, token_type: 'Bearer', expires_in: st.exportTtl, expires_at: new Date(claims.exp * 1000).toISOString(), scope: cap.join(' '), audience: body.audience, env: body.env, purpose: 'export', subject: claims.sub, project_id: p.id, jti: claims.jti });
+ }
+ return problem(json, 404, 'route.not_found', 'no such route');
+ }
+ const res204 = (json) => json(204, {});
+
+ function mintApp(a, audience, cap) {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, privatePem);
+ }
+
+ const srv = await listen(async (req, raw, json, res) => {
+ const url = new URL(req.url, 'http://x');
+ st.requests.push({ method: req.method, path: url.pathname, query: url.search, headers: req.headers });
+ if (st.down) return problem(json, 503, 'network.down', 'Network is restarting');
+ if (url.pathname === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
+ if (url.pathname === '/.well-known/openvibe') return json(200, { name: 'OpenVibe', issuer, contracts: { version: require('openvibe-contracts/package.json').version }, services: [] });
+ if (url.pathname === '/api/v1/registry/services') {
+ return json(200, { services: [{ id: 'network', name: 'OpenVibe.Network', status: 'stable', domains: ['openvibe.network'], capabilities: [], runtime: { status: 'up', basis: 'ready', checked_at: new Date().toISOString() } }] });
+ }
+ if (url.pathname === '/oauth/revoke') return json(200, {});
+ if (url.pathname === '/oauth/token' && req.method === 'POST') {
+ const ct = String(req.headers['content-type'] || '');
+ const body = ct.includes('json') ? JSON.parse(raw.toString('utf8') || '{}') : Object.fromEntries(new URLSearchParams(raw.toString('utf8')));
+ st.tokenRequests.push({ grant_type: body.grant_type, client_id: body.client_id, audience: body.audience, scope: body.scope });
+ if (body.client_id === 'inventory') {
+ if (body.client_secret !== 'inventory-secret') return json(401, { error: 'invalid_client', error_description: 'bad client secret' });
+ if (body.grant_type === 'authorization_code') {
+ const c = st.codes.get(body.code);
+ if (!c) return json(400, { error: 'invalid_grant', error_description: 'unknown code' });
+ st.codes.delete(body.code);
+ const challenge = crypto.createHash('sha256').update(String(body.code_verifier || '')).digest('base64url');
+ if (challenge !== c.challenge) return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
+ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;
+ st.refresh.set(rt, c.user);
+ return json(200, { access_token: userToken(c.user), refresh_token: rt, token_type: 'Bearer' });
+ }
+ if (body.grant_type === 'refresh_token') {
+ const user = st.refresh.get(body.refresh_token);
+ if (!user) return json(400, { error: 'invalid_grant', error_description: 'refresh token rejected' });
+ st.refresh.delete(body.refresh_token);
+ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;
+ st.refresh.set(rt, user);
+ return json(200, { access_token: userToken(user), refresh_token: rt, token_type: 'Bearer' });
+ }
+ if (body.grant_type === 'client_credentials') {
+ const now = Math.floor(Date.now() / 1000);
+ return json(200, { access_token: serviceAuth.signServiceToken({ iss: issuer, sub: 'svc:inventory', actor_type: 'service', aud: [body.audience || 'openvibe.events'], cap: String(body.scope || '').split(/\s+/).filter(Boolean), iat: now, exp: now + 300, jti: crypto.randomUUID() }, privatePem), token_type: 'Bearer', expires_in: 300 });
+ }
+ }
+ const a = st.apps.get(body.client_id);
+ if (!a || a.revoked_at) return json(401, { error: 'invalid_client', error_description: 'unknown or revoked app' });
+ const cred = [...st.creds.values()].find((c) => c.app_id === a.id && c.hash === sha256(body.client_secret) && credState(c) !== 'revoked' && credState(c) !== 'expired');
+ if (!cred) return json(401, { error: 'invalid_client', error_description: 'bad client secret' });
+ if (a.environment === 'sandbox' && !sandboxAudiences.includes(body.audience)) return json(400, { error: 'invalid_target', error_description: `${body.audience} does not accept sandbox tokens` });
+ const p = st.projects.get(a.project_id);
+ const held = [...st.grants.values()].filter((g) => g.app_id === a.id && g.status === 'approved' && g.audience === body.audience && p.allowance.includes(g.capability)).map((g) => g.capability);
+ const wanted = body.scope ? String(body.scope).split(/\s+/).filter(Boolean) : held;
+ const missing = wanted.filter((w) => !held.includes(w));
+ if (missing.length) return json(400, { error: 'invalid_scope', error_description: `not granted: ${missing.join(' ')}` });
+ if (!wanted.length) return json(400, { error: 'invalid_scope', error_description: `no grants for audience ${body.audience}` });
+ return json(200, { access_token: mintApp(a, body.audience, wanted), token_type: 'Bearer', expires_in: 300, scope: wanted.join(' ') });
+ }
+ if (url.pathname.startsWith('/api/v1/projects')) {
+ const u = userFrom(req);
+ if (u === 'expired') return problem(json, 401, 'auth.expired', 'access token expired; refresh it');
+ if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');
+ if (req.headers['x-internal-key']) return problem(json, 400, 'test.internal_key_seen', 'X-Internal-Key must never be sent');
+ const parts = url.pathname.replace(/^\/api\/v1\/projects\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);
+ return await projectsApi(req, raw, json, u, parts, url.searchParams);
+ }
+ return json(404, { error: 'not found' });
+ });
+ issuer = srv.url;
+
+ return {
+ url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,
+ addUser, userToken,
+ /** A first-party service's token for this service's audience, holding `cap` (e.g. live with inventory.item.grant). */
+ serviceToken(slug, cap, { audience = 'inventory.openvibe.network' } = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({ iss: issuer, sub: `svc:${slug}`, actor_type: 'service', aud: [audience], cap, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, privatePem);
+ },
+ /** An authorization code the mock will exchange for this user if the verifier matches. */
+ issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
+ setAllowance(projectId, caps) { st.projects.get(projectId).allowance = caps; },
+ setEnvironmentPolicy(projectId, policy) { st.projects.get(projectId).environment_policy = policy; },
+ setUsage(projectId, body) { st.usage.set(projectId, body); },
+ setQuota(projectId, capability, limit, window, unit) { st.quotas.set(`${projectId} ${capability}`, { project_id: projectId, capability, limit, window, unit, updated_at: new Date().toISOString() }); },
+ setCatalog(list) { st.catalog = list; },
+ setDown(v) { st.down = v; },
+ mintApp: (appId, audience, cap, extra = {}) => { const a = st.apps.get(appId); const now = Math.floor(Date.now() / 1000); return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...extra }, privatePem); },
+ close: srv.close,
+ };
+}
+
+module.exports = { startNetwork, signJwt };
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+/**
+ * ADR-054 §8: Live's cosmetics move here row for row. A stand-in Live database (Live's own three columns) holds two
+ * people's unlocks and equipped slots, an account without a Network subject and an item Live no longer sells. The dry
+ * run changes nothing; the import grants with origin `migrated` and the unlock time, equips the slots, and a second run
+ * grants nothing again; verify then reports no difference.
+ */
+const assert = require('assert');
+const { createDb } = require('openvibe-sdk/db');
+const { boot, check, done } = require('./helpers/boot');
+const { importLive, verifyLive, isoOf } = require('../server/inventory/import-live');
+
+(async () => {
+ const t = await boot();
+ const live = createDb({ pglite: true });
+ const db = t.ctx.s.db;
+ const inv = t.ctx.inv;
+ const A = 'usr_01JZ0000000000000000000AAA';
+ const B = 'usr_01JZ0000000000000000000BBB';
+ const quiet = { log() {}, warn() {} };
+
+ try {
+ await live.exec(`CREATE TABLE users (id bigint PRIMARY KEY, subject_id text);
+ CREATE TABLE user_cosmetics (id bigint GENERATED ALWAYS AS IDENTITY, user_id bigint, item_id text, category text, unlocked_at text);
+ CREATE TABLE user_equipped (user_id bigint, slot text, item_id text);
+ INSERT INTO users VALUES (1, '${A}'), (2, '${B}'), (3, NULL);
+ INSERT INTO user_cosmetics (user_id, item_id, category, unlocked_at) VALUES
+ (1, 'fx_rainbow', 'name_effect', '2026-09-01 12:00:00'), (1, 'hat_crown', 'hat', '2026-09-02 08:30:00'), (1, 'gary', 'voice', '2026-09-03 00:00:00'),
+ (2, 'fx_fire', 'name_effect', '2026-09-04 10:00:00'), (2, 'fx_retired_thing', 'name_effect', '2026-09-05 10:00:00'),
+ (3, 'fx_ice', 'name_effect', '2026-09-06 10:00:00');
+ INSERT INTO user_equipped VALUES (1, 'name_effect', 'fx_rainbow'), (1, 'hat', 'hat_crown'), (2, 'name_effect', 'fx_fire'), (2, 'particle', 'px_hearts');`);
+
+ await check('the dry run counts and changes nothing', async () => {
+ const r = await importLive({ live, inv, db, apply: false, log: quiet });
+ assert.deepStrictEqual([r.people, r.unlocks, r.noSubject, r.granted, r.equipped], [2, 6, 1, 4, 4]);
+ assert.deepStrictEqual(r.unknown.sort(), ['fx_retired_thing'], 'an item Live no longer sells is named, not guessed');
+ assert.strictEqual(Number(await db.value('SELECT count(*) FROM inventory_instances')), 0);
+ });
+
+ await check('the import grants with origin migrated and the unlock time, and equips the slots', async () => {
+ const r = await importLive({ live, inv, db, apply: true, log: quiet });
+ assert.deepStrictEqual([r.granted, r.alreadyHere, r.equipped, r.notOwned], [4, 0, 3, 1], 'bob wears hearts he never unlocked: left empty');
+ const mine = (await inv.inventory(A, { own: true })).instances;
+ assert.strictEqual(mine.length, 3);
+ assert.ok(mine.every((i) => i.origin === 'migrated'));
+ const crown = await inv.byAlias('service:live', 'hat_crown');
+ assert.strictEqual(mine.find((i) => i.definition_id === crown.id).acquired_at, isoOf('2026-09-02 08:30:00'));
+ const eq = (await inv.equipped([A]))[0].slots;
+ assert.deepStrictEqual(Object.keys(eq).sort(), ['live.hat:hat', 'live.name_effect:name_effect']);
+ });
+
+ await check('a second run grants nothing again, and verify finds no difference', async () => {
+ const again = await importLive({ live, inv, db, apply: true, log: quiet });
+ assert.deepStrictEqual([again.granted, again.alreadyHere], [0, 4]);
+ assert.strictEqual(Number(await db.value('SELECT count(*) FROM inventory_instances')), 4);
+ const v = await verifyLive({ live, inv, db });
+ assert.deepStrictEqual(v.differences, []);
+ });
+
+ await check('verify names a person who differs', async () => {
+ await live.exec("INSERT INTO user_cosmetics (user_id, item_id, category, unlocked_at) VALUES (1, 'px_sparkle', 'particle', '2026-09-07 00:00:00')");
+ const v = await verifyLive({ live, inv, db });
+ assert.deepStrictEqual(v.differences, [{ subject: A, missing: ['px_sparkle'], slots: [] }]);
+ });
+ } finally {
+ await live.close();
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,141 @@
@@ -0,0 +1,141 @@
+'use strict';
+/**
+ * The inventory (ADR-054) through its API: Live's kinds and cosmetics are seeded at boot; an issuer grants within its
+ * own namespace only, idempotently and within a supply cap; a person sees and equips what they own; the public reads
+ * show owned items of published definitions; a batch of equipped sets is one call; every movement writes a ledger row
+ * and an event; and nothing anywhere sells, buys, trades or converts an item.
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const LIVE = t.network.serviceToken('live', ['inventory.item.read', 'inventory.item.grant', 'inventory.item.consume', 'inventory.definition.manage', 'inventory.item.list', 'inventory.equip.manage']);
+ const QUEST = t.network.serviceToken('quest', ['inventory.item.grant', 'inventory.definition.manage']);
+ const NOREAD = t.network.serviceToken('search', []);
+ const api = (path, o = {}) => t.get(`/api/v1${path}`, o);
+ const valid = (id, body) => { const v = contracts.validate(id, body); assert.ok(v.valid, `${id}: ${JSON.stringify(v.errors)}`); };
+ const events = async (type) => (await t.ctx.s.db.many('SELECT envelope FROM event_outbox ORDER BY id')).map((r) => r.envelope).filter((e) => e.event_type === type);
+ let rainbow;
+ let crown;
+ let granted;
+
+ try {
+ await check('boot registers Live\'s five kinds and 70 cosmetics, published, found by Live\'s ids', async () => {
+ const kinds = (await api('/kinds')).json().kinds;
+ assert.deepStrictEqual(kinds.map((k) => k.id), ['live.chat_tag', 'live.hat', 'live.name_effect', 'live.particle', 'live.voice']);
+ for (const k of kinds) valid('inventory.kind@1', k);
+ const defs = (await api('/definitions?limit=500')).json().definitions;
+ assert.strictEqual(defs.length, 70);
+ for (const d of defs) valid('inventory.definition@1', d);
+ rainbow = defs.find((d) => (d.aliases || []).includes('fx_rainbow'));
+ crown = defs.find((d) => (d.aliases || []).includes('hat_crown'));
+ assert.deepStrictEqual([rainbow.kind, rainbow.rarity, rainbow.art.token, rainbow.issuer], ['live.name_effect', 'common', 'name-fx-rainbow', 'service:live']);
+ assert.strictEqual(crown.rarity, 'epic');
+ // A second boot on the same database seeds nothing again.
+ const { seed } = require('../server/inventory/seed');
+ assert.deepStrictEqual(await seed(t.ctx.inv, { log: { log() {} } }), { kinds: 5, definitions: 0 });
+ });
+
+ await check('an issuer grants within its namespace, idempotently; a non-stackable item already owned answers the one held', async () => {
+ const g = await api('/grants', { bearer: LIVE, json: { definition_id: rainbow.id, subject: alice.subject, idempotency_key: 'grant-0000-0001', reason: 'test' } });
+ assert.strictEqual(g.status, 201, g.text);
+ granted = g.json().instance;
+ valid('inventory.instance@1', granted);
+ assert.deepStrictEqual([granted.owner, granted.origin, granted.state], [alice.subject, 'granted', 'owned']);
+ const again = await api('/grants', { bearer: LIVE, json: { definition_id: rainbow.id, subject: alice.subject, idempotency_key: 'grant-0000-0001' } });
+ assert.deepStrictEqual([again.status, again.json().instance.id, again.json().created], [200, granted.id, false]);
+ const other = await api('/grants', { bearer: LIVE, json: { definition_id: rainbow.id, subject: alice.subject, idempotency_key: 'grant-0000-0002' } });
+ assert.deepStrictEqual([other.status, other.json().instance.id], [200, granted.id], 'not stackable: the one she has');
+ assert.strictEqual((await events('inventory.item.granted')).length, 1);
+ const ev = (await events('inventory.item.granted'))[0];
+ valid('inventory.item.granted', ev.payload);
+ assert.deepStrictEqual([ev.subject, ev.visibility], [{ type: 'user', id: alice.subject }, 'subject']);
+ });
+
+ await check('only the kind\'s issuer defines or grants its items; people and missing capabilities are refused', async () => {
+ const wrong = await api('/grants', { bearer: QUEST, json: { definition_id: rainbow.id, subject: bob.subject, idempotency_key: 'quest-0000-0001' } });
+ assert.deepStrictEqual([wrong.status, wrong.json().code], [403, 'inventory.not_issuer']);
+ const def = await api('/definitions', { bearer: QUEST, json: { kind: 'live.hat', name: 'Quest hat', art: { emoji: '🎩' }, rarity: 'rare', attributes: { tier: 3 } } });
+ assert.deepStrictEqual([def.status, def.json().code], [403, 'inventory.not_issuer']);
+ const person = await api('/grants', { as: alice, method: 'POST', json: { definition_id: rainbow.id, subject: alice.subject, idempotency_key: 'self-0000-0001' }, headers: { origin: t.base } });
+ assert.deepStrictEqual([person.status, person.json().code], [403, 'inventory.not_issuer'], 'a person is not an issuer');
+ const noCap = await api('/grants', { bearer: NOREAD, json: { definition_id: rainbow.id, subject: bob.subject, idempotency_key: 'none-0000-0001' } });
+ assert.strictEqual(noCap.status, 403);
+ const bought = await api('/grants', { bearer: LIVE, json: { definition_id: rainbow.id, subject: bob.subject, idempotency_key: 'buy-0000-0001', origin: 'bought' } });
+ assert.deepStrictEqual([bought.status, bought.json().code], [422, 'inventory.bad_origin'], 'nothing is bought (ADR-054 §5)');
+ });
+
+ await check('a capped item numbers its instances and stops at the cap; attributes must fit the kind', async () => {
+ const bad = await api('/definitions', { bearer: LIVE, json: { kind: 'live.hat', name: 'Broken', art: { emoji: '🎩' }, rarity: 'rare', attributes: { tier: 99 } } });
+ assert.deepStrictEqual([bad.status, bad.json().code], [422, 'inventory.bad_attributes']);
+ const made = await api('/definitions', { bearer: LIVE, json: { kind: 'live.hat', name: 'Launch Cap', art: { emoji: '🧢' }, rarity: 'legendary', attributes: { tier: 6, hat_char: '🧢' }, supply_cap: 2, publish: true, aliases: ['hat_launch_cap'] } });
+ assert.strictEqual(made.status, 201, made.text);
+ const capId = made.json().definition.id;
+ assert.strictEqual((await events('inventory.definition.published')).filter((e) => e.payload.definition_id === capId).length, 1);
+ const a = await api('/grants', { bearer: LIVE, json: { definition_id: capId, subject: alice.subject, idempotency_key: 'cap-0000-0001' } });
+ const b = await api('/grants', { bearer: LIVE, json: { definition_id: capId, subject: bob.subject, idempotency_key: 'cap-0000-0002' } });
+ assert.deepStrictEqual([a.json().instance.serial, b.json().instance.serial], [1, 2]);
+ const c = await api('/grants', { bearer: LIVE, json: { definition_id: capId, subject: t.network.addUser('carol').subject, idempotency_key: 'cap-0000-0003' } });
+ assert.deepStrictEqual([c.status, c.json().code], [409, 'inventory.supply_exhausted']);
+ const locked = await api(`/definitions/${capId}`, { bearer: LIVE, method: 'PATCH', json: { rarity: 'common' } });
+ assert.deepStrictEqual([locked.status, locked.json().code], [409, 'inventory.published'], 'rarity is fixed once people own it');
+ });
+
+ await check('a person equips what they own, one item per slot; a batch read is one call', async () => {
+ const eq = await api('/me/equipped', { as: alice, method: 'PUT', json: { kind: 'live.name_effect', slot: 'name_effect', instance_id: granted.id }, headers: { origin: t.base } });
+ assert.strictEqual(eq.status, 200, eq.text);
+ valid('inventory.equipped@1', eq.json());
+ assert.strictEqual(eq.json().slots['live.name_effect:name_effect'].token, 'name-fx-rainbow');
+ const notMine = await api('/me/equipped', { as: bob, method: 'PUT', json: { kind: 'live.name_effect', slot: 'name_effect', instance_id: granted.id }, headers: { origin: t.base } });
+ assert.deepStrictEqual([notMine.status, notMine.json().code], [409, 'inventory.not_owned']);
+ const wrongSlot = await api('/me/equipped', { as: alice, method: 'PUT', json: { kind: 'live.name_effect', slot: 'hat', instance_id: granted.id }, headers: { origin: t.base } });
+ assert.deepStrictEqual([wrongSlot.status, wrongSlot.json().code], [422, 'inventory.bad_slot']);
+ const crossSite = await api('/me/equipped', { as: alice, method: 'PUT', json: { kind: 'live.name_effect', slot: 'name_effect', instance_id: null }, headers: { origin: 'https://evil.example' } });
+ assert.strictEqual(crossSite.status, 403, 'a cookie write from another site is refused');
+ const batch = await api(`/equipped?subjects=${alice.subject},${bob.subject}`);
+ valid('inventory.equipped-batch@1', batch.json());
+ assert.deepStrictEqual(batch.json().equipped.map((e) => Object.keys(e.slots).length), [1, 0]);
+ const forLive = await api('/me/equipped', { bearer: LIVE, method: 'PUT', json: { kind: 'live.name_effect', slot: 'name_effect', instance_id: null }, headers: { 'X-OV-Subject': alice.subject } });
+ assert.deepStrictEqual([forLive.status, Object.keys(forLive.json().slots).length], [200, 0], 'Live clears the slot acting for her');
+ assert.strictEqual((await events('inventory.item.unequipped')).length, 1);
+ });
+
+ await check('the public inventory shows owned items; the person\'s own read shows every state; consume and revoke', async () => {
+ const pub = await api(`/people/${alice.subject}/items`);
+ valid('inventory.inventory@1', pub.json());
+ assert.strictEqual(pub.json().instances.length, 2);
+ const used = await api(`/instances/${granted.id}/consume`, { bearer: LIVE, method: 'POST', json: {} });
+ assert.strictEqual(used.json().instance.state, 'consumed');
+ assert.strictEqual((await api(`/people/${alice.subject}/items`)).json().instances.length, 1, 'used up: off the public page');
+ const own = await api('/me/items', { as: alice });
+ assert.deepStrictEqual(own.json().instances.map((i) => i.state).sort(), ['consumed', 'owned']);
+ const twice = await api(`/instances/${granted.id}/revoke`, { bearer: LIVE, method: 'POST', json: { reason: 'x' } });
+ assert.deepStrictEqual([twice.status, twice.json().code], [409, 'inventory.not_owned']);
+ const notIssuer = await api(`/instances/${granted.id}/consume`, { bearer: QUEST, method: 'POST', json: {} });
+ assert.strictEqual(notIssuer.status, 403);
+ const ledger = await t.ctx.s.db.many('SELECT action FROM inventory_ledger WHERE instance_id = $1 ORDER BY id', [granted.id]);
+ assert.deepStrictEqual(ledger.map((r) => r.action), ['granted', 'equipped', 'unequipped', 'consumed']);
+ });
+
+ await check('a presented token must hold inventory.item.read; anyone reads without one', async () => {
+ assert.strictEqual((await api('/kinds')).status, 200);
+ assert.strictEqual((await api('/kinds', { bearer: NOREAD })).status, 403);
+ assert.strictEqual((await api('/kinds', { bearer: LIVE })).status, 200);
+ });
+
+ await check('no route sells, buys, trades or converts an item (ADR-054 §5)', async () => {
+ const fs = require('fs');
+ const routes = fs.readFileSync(require.resolve('../server/http/api.js'), 'utf8') + fs.readFileSync(require.resolve('../server/http/pages.js'), 'utf8');
+ for (const word of ['/buy', '/sell', '/trade', '/market', '/price', '/convert', '/cashout', '/case', '/drop']) assert.ok(!routes.includes(`'${word}`) && !routes.includes(`"${word}`), `no ${word} route`);
+ for (const word of ['buy', 'sell', 'trade', 'market']) assert.strictEqual((await api(`/${word}`, { method: 'POST', json: {} })).status, 404, `POST /api/v1/${word} does not exist`);
+ assert.ok(!/price|amount|vibes|coins/i.test(fs.readFileSync(require.resolve('../migrations/0001_initial.sql'), 'utf8').replace(/--.*$/gm, '')), 'no money column');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+/**
+ * Every page carries the openvibe-shared boost marker and self-starting script (plan T11), so a
+ * same-site click swaps <main> in place without a reload. The marker names the layout's release,
+ * which the app sets from openvibe-shared/release (a swap only happens between pages of the same
+ * release; across a deploy it is a normal load), the boost tag names #main in data-main, and the
+ * shared navbar's sign-in follows the current page through the {path} template.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const MARKER = /<meta name="ov-boost" content="(inventory@[0-9A-Za-z._+-]{1,64})">/;
+const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#main" defer><\/script>/;
+
+(async () => {
+ const t = await boot();
+ const user = t.network.addUser('kim');
+ const release = (await t.get('/release.json')).json().release;
+
+ await check('every rendered page carries the ov-boost marker (the app\'s release) and the data-main boost script', async () => {
+ for (const p of ['/', '/updates']) {
+ const r = await t.get(p);
+ assert.strictEqual(r.status, 200, p);
+ const m = r.text.match(MARKER);
+ assert.ok(m, `${p}: no ov-boost marker`);
+ assert.strictEqual(m[1], `inventory@${release}`, `${p}: the marker is not the app's release`);
+ assert.match(r.text, BOOST, `${p}: no boost script with data-main`);
+ assert.match(r.text, /<main id="main"/, `${p}: the page's main is not #main`);
+ }
+ const signedIn = await t.get('/updates', { as: user });
+ assert.strictEqual(signedIn.status, 200);
+ assert.match(signedIn.text, MARKER, '/updates signed in: no ov-boost marker');
+ assert.match(signedIn.text, BOOST, '/updates signed in: no boost script');
+ });
+
+ await check('the home page head comes from openvibe-shared/shell with the same title, canonical, robots and JSON-LD', async () => {
+ const r = await t.get('/');
+ assert.strictEqual(r.status, 200);
+ const head = r.text.slice(0, r.text.indexOf('</head>'));
+ assert.strictEqual((r.text.match(/<title>/g) || []).length, 1, 'exactly one <title>');
+ assert.ok(head.includes('<title>OpenVibe.Inventory: Your items, on every OpenVibe site.</title>'), 'home title');
+ assert.ok(head.includes('<link rel="canonical" href="https://inventory.openvibe.network/">'), 'canonical');
+ assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
+ const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
+ assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');
+ for (const tag of ['<meta property="og:title"', 'data-ov-icon="inventory"', '/css/app.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
+ assert.ok(head.includes(tag), `head has ${tag}`);
+ }
+ assert.ok(r.text.includes('id="ov-footer"'), 'the server-rendered footer');
+ assert.ok(r.text.includes('OpenVibeFooter.init(window.__OV_PAGE.footer)'), 'the footer is initialised');
+ });
+
+ await check('the navbar config uses the {path} login template, not the path baked in', async () => {
+ const r = await t.get('/updates');
+ assert.match(r.text, /"loginUrl":"\/auth\/login\?next=\{path\}"/);
+ assert.ok(!/"loginUrl":"\/auth\/login\?next=%2F/.test(r.text), 'loginUrl still has the current path baked in');
+ });
+
+ await check('the home page shows the service name and the tagline server-side, with no local path', async () => {
+ const r = await t.get('/');
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.match(r.headers.get('content-type'), /^text\/html/);
+ assert.ok(r.text.includes('OpenVibe.Inventory'), 'the service name is on the page');
+ assert.ok(r.text.includes('Your items, on every OpenVibe site.'), 'the tagline is on the page');
+ assert.ok(r.text.includes('/shared/showcase.css'), 'the showcase stylesheet');
+ assert.ok(!r.text.includes('/home/') && !r.text.includes('/mnt/'), 'no local filesystem path on the page');
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+'use strict';
+// deploy/nginx/inventory.openvibe.network.conf: the shared navbar asks /auth/me on every page view, so the session probe has its own location on
+// the API zone; the sign-in routes keep the strict zone (10 a minute). Both answer 429 when limited, not
+// nginx's default 503 (the browser check, OpenVibe.Host scripts/browser-check.js, found /auth/me answering
+// 503 after about ten quick page loads, which the navbar reads as signed out).
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const conf = fs.readFileSync(path.join(__dirname, '..', ...'deploy/nginx/inventory.openvibe.network.conf'.split('/')), 'utf8');
+const block = (loc) => { const m = conf.match(new RegExp(`\\n location ${loc} \\{\\n([\\s\\S]*?)\\n \\}`)); return m && m[1]; };
+const probe = block('= /auth/me'), auth = block('/auth/');
+assert.ok(probe && auth, 'both locations');
+assert.ok(conf.indexOf('location = /auth/me') < conf.indexOf('location /auth/ {'));
+assert.match(probe, /limit_req zone=inventory_api burst=30 nodelay;/);
+assert.match(auth, /limit_req zone=inventory_auth burst=10 nodelay;/);
+for (const b of [probe, auth]) assert.match(b, /limit_req_status 429;/);
+const upstream = (b) => (b.match(/proxy_pass (\S+);/) || [])[1];
+assert.ok(upstream(probe) && upstream(probe) === upstream(auth), 'the same upstream');
+console.log('nginx auth limit: /auth/me on the API zone, 429 when limited');
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+'use strict';
+/**
+ * No X-Internal-Key anywhere (ADR-014: "No developer-facing path ever accepts X-Internal-Key").
+ * Code, client scripts, deploy files, the environment template and the manifest must not
+ * mention it; the running service never sends it to Network (the Network mock refuses any request
+ * that carries one), and a request that presents one gets no special treatment.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./helpers/boot');
+
+const ROOT = path.join(__dirname, '..');
+const PATTERN = /x-internal-key|internal[_-]?key|INTERNAL_API_KEY/i;
+
+function files(dir) {
+ const out = [];
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ if (e.name === 'node_modules' || e.name.startsWith('.git')) continue;
+ const p = path.join(dir, e.name);
+ if (e.isDirectory()) out.push(...files(p)); else out.push(p);
+ }
+ return out;
+}
+
+(async () => {
+ await check('no mention in server/, public/, deploy/, docs/*.json, .env.example, package.json or CI', async () => {
+ const targets = [
+ ...files(path.join(ROOT, 'server')), ...files(path.join(ROOT, 'public')), ...files(path.join(ROOT, 'deploy')),
+ ...(fs.existsSync(path.join(ROOT, 'docs')) ? files(path.join(ROOT, 'docs')).filter((f) => f.endsWith('.json')) : []),
+ path.join(ROOT, '.env.example'), path.join(ROOT, 'package.json'), path.join(ROOT, '.github', 'workflows', 'ci.yml'),
+ ].filter((f) => fs.existsSync(f));
+ assert.ok(targets.length > 15);
+ const hits = targets.filter((f) => PATTERN.test(fs.readFileSync(f, 'utf8')));
+ assert.deepStrictEqual(hits.map((f) => path.relative(ROOT, f)), []);
+ });
+
+ const t = await boot();
+ await check('a whole session (sign-in, every page, the API) never sends an internal key to Network', async () => {
+ const u = t.network.addUser('nia');
+ for (const p of ['/', '/updates', '/api/v1/ping', '/auth/me']) await t.get(p, { as: u });
+ const sent = t.network.requests.filter((r) => r.headers['x-internal-key']);
+ assert.strictEqual(sent.length, 0);
+ });
+
+ await check('presenting X-Internal-Key changes nothing: the public routes answer as for anyone', async () => {
+ const r = await t.get('/api/v1/ping', { headers: { 'x-internal-key': 'anything' } });
+ assert.strictEqual(r.status, 200);
+ const me = await t.get('/auth/me', { headers: { 'x-internal-key': 'anything' } });
+ assert.deepStrictEqual(JSON.parse(me.text), { user: null }, 'the key signs nobody in');
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+'use strict';
+// Open redirect regression (WS-R task 5): the sign-in "next" never leaves the site. Browsers drop tab and
+// newline characters from a URL and read a backslash as "/", so "/<TAB>/evil.com" is "//evil.com" to them;
+// until 2026-09-26 it got through here.
+const assert = require('assert');
+const { sanitizeNext } = require('../server/auth/sso');
+
+const config = { baseUrl: 'https://site.openvibe.test', networkUrl: 'https://openvibe.network' };
+const home = (v) => {
+ const out = String(sanitizeNext(v, config));
+ const host = new URL(out.replace(/[\t\n\r]/g, ''), 'https://site.openvibe.test').host;
+ return host === 'site.openvibe.test' || host === 'openvibe.network';
+};
+for (const v of ['//evil.com', '/\\evil.com', '\\\\evil.com', 'https://evil.com', 'http:evil.com', '/\t/evil.com', '/\n/evil.com', '\t//evil.com',
+ ' //evil.com', '/\\/evil.com', 'https:/\\evil.com', '///evil.com', 'javascript:alert(1)', '/%09/evil.com']) {
+ assert.ok(home(v), `${JSON.stringify(v)} stays on the site (got ${JSON.stringify(sanitizeNext(v, config))})`);
+ assert.ok(!/^\s*javascript:/i.test(String(sanitizeNext(v, config))), JSON.stringify(v));
+}
+assert.strictEqual(sanitizeNext('/settings?tab=a', config), '/settings?tab=a', 'a plain relative path is kept');
+console.log('open redirect: all checks passed');
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+'use strict';
+// Size budgets for inventory.openvibe.network's home page (roadmap WS-T task 1, openvibe-shared/perf-budget): the
+// server as it runs (a fresh database), measured without a browser. Budgets sit about 10% above the
+// 2026-10-08 measurement (see below); raising one is a decision to state in the commit.
+// node test/perf-budget.test.js
+const assert = require('assert');
+const fs = require('fs');
+const net = require('net');
+const os = require('os');
+const path = require('path');
+const { spawn } = require('child_process');
+const { measure, check, format } = require('openvibe-shared/perf-budget');
+
+// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%
+// headroom; file counts are the measured counts and external files stay at 0.
+const BUDGETS = {
+ htmlRawKB: 27.5, // measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)
+ htmlBrotliKB: 6.9, // 6.2
+ jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
+ jsRawKB: 264, // 239.8
+ jsBrotliKB: 62.5, // 56.5
+ cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)
+ cssRawKB: 23, // 20.5 with the inventory's cards, rarity tiles and hero panel (2026-10-09)
+ cssBrotliKB: 5.5, // 4.9
+ externalFiles: 0, // 0
+};
+
+const freePort = () => new Promise((resolve) => { const s = net.createServer(); s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); }); });
+
+(async () => {
+ // A database of its own (not the shared dev PGlite in data/pglite, and not whatever the caller's
+ // DATABASE_URL names), so the measurement is the server on a fresh database and the run leaves nothing.
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-budget-'));
+ const pgliteDir = path.join(dir, 'pglite');
+ const port = await freePort();
+ const child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {
+ cwd: path.join(__dirname, '..'),
+ env: {
+ ...process.env, PORT: String(port), HOST: '127.0.0.1', NODE_ENV: 'test',
+ DATABASE_URL: '', DATABASE_DIRECT_URL: '', VALKEY_URL: '', INVENTORY_PGLITE_DIR: pgliteDir,
+ },
+ stdio: ['ignore', 'ignore', 'pipe'],
+ });
+ let stderr = '';
+ child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-2000); });
+ const base = `http://127.0.0.1:${port}`;
+ try {
+ let up = false;
+ for (let i = 0; i < 600 && !up; i++) {
+ up = await fetch(`${base}/api/health`).then((r) => r.ok).catch(() => false);
+ if (!up) await new Promise((r) => setTimeout(r, 100));
+ }
+ assert.ok(up, `the server did not start:\n${stderr}`);
+ assert.ok(fs.existsSync(pgliteDir), 'the server did not use the isolated database (INVENTORY_PGLITE_DIR)');
+ const m = await measure({ base });
+ const over = check(m, BUDGETS);
+ assert.deepStrictEqual(over, [], format(m, over));
+ console.log(format(m));
+ console.log('perf budget: all checks passed');
+ } finally {
+ child.kill();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+})().catch((err) => { console.error(err); process.exitCode = 1; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+#!/usr/bin/env node
+/**
+ * Runs every test/*.test.js in its own process and fails if any fails. They use temp PGlite
+ * databases and an in-process mock of OpenVibe.Network; none needs the network or a running site.
+ *
+ * npm test # everything
+ * npm test -- login # only files whose name contains one of the words
+ * npm test -- --strict # a skipped test fails the run too
+ *
+ * A test that cannot run something here prints `<label>: skipped (<why>)`: that file is listed with
+ * ○ and not counted as passed (openvibe-shared/test-runner).
+ */
+'use strict';
+const { run } = require('openvibe-shared/test-runner');
+
+// VERBOSE=1 also prints every file's output, then the summary again.
+run({ dir: __dirname, timeoutMs: 60000, pad: 34, parallel: 1 }).then((r) => {
+ if (process.env.VERBOSE) {
+ for (const t of r.results) console.log(`\n── ${t.file} ──\n${t.output.trimEnd()}`);
+ console.log(`\n${r.summary}`);
+ }
+ process.exit(r.exitCode);
+}, (err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+'use strict';
+/**
+ * The crawler behind the security suites (roadmap WS-R task 5): lists every route of the booted
+ * app (walking Express's router stack, so a route added later is crawled without anyone listing
+ * it), fills route parameters with seeded values, and GETs each path as several people, reporting
+ * any response whose body or headers carry a value that person must never see.
+ * Not a test itself (no .test.js); used with test/helpers/boot.js.
+ */
+
+/** The path an Express 4 layer is mounted at ('' for app-level middleware), or null when it is a pattern. */
+function mountPath(layer) {
+ if (!layer.regexp || layer.regexp.fast_slash) return '';
+ let src = layer.regexp.source.replace(/^\^/, '').replace(/\\\/\?\(\?=\\\/\|\$\)$/i, '').replace(/\/\?\(\?=\/\|\$\)$/i, '');
+ let i = 0;
+ src = src.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => `:${(layer.keys[i++] || {}).name || 'param'}`);
+ src = src.replace(/\\\//g, '/').replace(/\\\./g, '.').replace(/\\-/g, '-');
+ return /[\\^$()|[\]*+?]/.test(src) ? null : src;
+}
+
+/** Every route of the booted app (captured from createApp): [{ path, methods }]. */
+function listRoutes(app) {
+ if (!app || !app._router) throw new Error('no Express app');
+ const out = [];
+ const walk = (stack, prefix) => {
+ for (const layer of stack) {
+ if (layer.route) {
+ const methods = Object.keys(layer.route.methods).filter((m) => layer.route.methods[m]);
+ for (const p of [].concat(layer.route.path)) if (typeof p === 'string') out.push({ path: prefix + p, methods });
+ } else if (layer.handle && Array.isArray(layer.handle.stack)) {
+ const mp = mountPath(layer);
+ if (mp !== null) walk(layer.handle.stack, prefix + mp);
+ } else {
+ const mp = mountPath(layer);
+ if (mp) out.push({ path: prefix + mp, methods: ['_all'] });
+ }
+ }
+ };
+ walk(app._router.stack, '');
+ const seen = new Set();
+ return out.filter((r) => { const k = `${r.methods.join(',')} ${r.path}`; if (seen.has(k)) return false; seen.add(k); return true; });
+}
+
+/** Concrete paths for a template: candidate i of every parameter, for each i (no cross product). */
+function expand(template, values) {
+ const names = [];
+ const t = `/${template.replace(/^\/+/, '')}`.replace(/\*/g, 'x').replace(/:([A-Za-z0-9_]+)\??(\([^)]*\))?/g, (m, n) => { names.push(n); return `:${n}`; });
+ if (!names.length) return [t];
+ const lists = names.map((n) => values(n));
+ const width = Math.max(...lists.map((l) => l.length));
+ const paths = new Set();
+ for (let i = 0; i < width; i++) {
+ let j = 0;
+ paths.add(t.replace(/:([A-Za-z0-9_]+)/g, () => { const l = lists[j++]; return encodeURIComponent(String(l[Math.min(i, l.length - 1)])); }));
+ }
+ return [...paths];
+}
+
+/** Every GET path: each GET route expanded, also with `query` appended, plus `extra`. */
+function getPaths(app, values, { query = '', extra = [] } = {}) {
+ const paths = new Set();
+ for (const r of listRoutes(app)) {
+ if (!r.methods.includes('get') && !r.methods.includes('_all')) continue;
+ for (const p of expand(r.path, values)) {
+ paths.add(p);
+ if (query && !p.includes('?')) paths.add(`${p}?${query}`);
+ }
+ }
+ for (const p of extra) paths.add(p);
+ return [...paths];
+}
+
+/** Which of `needles` ({ label: value }) a response carries: [{ label, where }]. */
+function leaks(res, needles) {
+ const found = [];
+ const headerText = [...(res.headers && typeof res.headers.entries === 'function' ? res.headers.entries() : Object.entries(res.headers || {}))].map(([k, v]) => `${k}: ${v}`).join('\n');
+ for (const [label, value] of Object.entries(needles)) {
+ if (!value) continue;
+ if (res.text && res.text.includes(value)) found.push({ label, where: 'body' });
+ if (headerText.includes(value)) found.push({ label, where: 'headers' });
+ }
+ return found;
+}
+
+/** A fresh client address per request (TRUST_PROXY is on in tests): the per-address rate limits are not what is being read. */
+let seq = 0;
+function nextAddress() { seq++; return `10.${(seq >> 16) & 255}.${(seq >> 8) & 255}.${seq & 255}`; }
+
+/**
+ * GET every path as every person ({ who: user | token | null }) through t.get (a user object signs
+ * in with the session cookie, a string is a bearer token). `needlesFor(who)` names what that person
+ * must never see. Resolves { found, answered, statuses }.
+ */
+async function crawl(t, paths, people, needlesFor, { concurrency = 6 } = {}) {
+ const found = [];
+ const statuses = {};
+ let answered = 0;
+ for (const [who, as] of Object.entries(people)) {
+ const needles = needlesFor(who);
+ const queue = [...paths];
+ await Promise.all(Array.from({ length: concurrency }, async () => {
+ while (queue.length) {
+ const p = queue.shift();
+ let r;
+ try { r = await t.get(p, { ...(as ? { as } : {}), headers: { 'x-forwarded-for': nextAddress() } }); } catch (e) { r = { status: 0, text: '', headers: {} }; }
+ if (r.status) answered++;
+ const cls = r.status ? `${String(r.status)[0]}xx` : 'none';
+ statuses[cls] = (statuses[cls] || 0) + 1;
+ for (const l of leaks(r, needles)) found.push(`${who}: GET ${p} → ${r.status} carries ${l.label} in its ${l.where}`);
+ }
+ }));
+ }
+ return { found, answered, statuses };
+}
+
+module.exports = { listRoutes, expand, getPaths, leaks, crawl, nextAddress };
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+/**
+ * The service's own secrets never leave in a response, an event or a log line (roadmap WS-R task 5, the
+ * internal-secret class). Its environment holds one: its Network OAuth client secret, a sentinel here. Every route
+ * the booted app has (listed from Express's router stack) is requested as anonymous, a signed-in person and a Network
+ * admin with real and nonsense ids, plus the sign-in callback with a forged code (it posts the client secret to
+ * Network), unknown paths and every write route with a broken body. No body or header may carry it, nor may the
+ * database or the captured log lines.
+ *
+ * node test/security-secrets.test.js
+ */
+const assert = require('assert');
+const appModule = require('../server/app');
+let capturedApp = null;
+const realCreate = appModule.createApp;
+appModule.createApp = async (...a) => { const built = await realCreate(...a); capturedApp = built.app; return built; };
+const { boot, check, done } = require('./helpers/boot');
+const { getPaths, crawl, listRoutes, expand, leaks, nextAddress } = require('./security-crawl');
+
+const SECRETS = { OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-oauth-client' };
+
+(async () => {
+ const t = await boot({ env: SECRETS });
+ const dev = t.network.addUser('dev');
+ const staff = t.network.addUser('root', { role: 'admin' });
+
+ await check('every GET route, as three people, with real and nonsense ids: no sentinel', async () => {
+ const nonsense = ['nope', "'\"<x>", 'x'.repeat(300)];
+ const values = () => ['inventory-id', ...nonsense];
+ const paths = getPaths(capturedApp, values, {
+ query: 'q=x&code=forged&state=forged&next=%2F',
+ extra: ['/api/health', '/api/ready', '/metrics', '/release.json', '/auth/callback?code=forged&state=forged', '/callback?code=forged&state=forged', '/api/nope', '/nope', '/.env', '/api/%'],
+ });
+ const r = await crawl(t, paths, { anonymous: null, dev, staff }, () => SECRETS);
+ assert.ok(r.answered >= paths.length * 2, `${r.answered} answers`);
+ assert.deepStrictEqual(r.found, []);
+ });
+
+ await check('every write route with a broken body, anonymous and as the developer: no sentinel', async () => {
+ const found = [];
+ const values = () => ['x'];
+ for (const route of listRoutes(capturedApp)) {
+ for (const method of route.methods.filter((m) => ['post', 'put', 'patch', 'delete'].includes(m))) {
+ for (const p of expand(route.path, values)) {
+ for (const as of [null, dev]) {
+ const r = await t.get(p, { method: method.toUpperCase(), ...(as ? { as } : {}), body: '{"broken": ', headers: { 'content-type': 'application/json', 'x-forwarded-for': nextAddress() } });
+ for (const l of leaks(r, SECRETS)) found.push(`${method} ${p} → ${r.status} carries ${l.label}`);
+ }
+ }
+ }
+ }
+ assert.deepStrictEqual(found, []);
+ });
+
+ await check('the database and the captured log lines carry no sentinel', async () => {
+ const text = await t.dbDump() + t.logs();
+ for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `${k} in the database or the logs`);
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+/**
+ * The inventory_at session cookie must hold a Network SESSION token (audience openvibe.network, a
+ * person), not just anything the Network signed. The Network signs FedCM assertions (audience =
+ * the relying party's origin, any https://*.openvibe.* origin), app/service tokens and internal
+ * tokens with the same key and issuer; none of them is a session here.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { signJwt } = require('./helpers/mocks');
+
+(async () => {
+ const s = await boot();
+ const staff = s.network.addUser('stafferson');
+ const APP = 'app_01JABCDEFGHJKMNPQRSTVWXYZ0';
+
+ const now = () => Math.floor(Date.now() / 1000);
+ // What POST /fedcm/assertion hands to ANY page on an owned zone the person continues on.
+ const fedcm = () => signJwt({
+ iss: s.network.url, sub: staff.id, id: staff.id, subject_id: staff.subject, username: staff.username, display_name: staff.username,
+ nonce: null, typ: 'fedcm', jti: 'x', aud: 'https://tenant.openvibe.host', iat: now(), exp: now() + 300,
+ }, s.network.privatePem);
+
+ await check('a FedCM assertion is not a session (it signs nobody in)', async () => {
+ const cookie = `inventory_at=${fedcm()}`;
+ const me = await s.get('/auth/me', { cookie });
+ assert.strictEqual(me.status, 401, 'an assertion for another site is not a session here');
+ const page = await s.get('/', { cookie });
+ assert.ok(!page.text.includes(`Signed in as <strong>${staff.username}`), 'the page shows nobody signed in');
+ });
+
+ await check('an app token is not a session', async () => {
+ const token = signJwt({ iss: s.network.url, sub: `app:${APP}`, actor_type: 'app', aud: ['openvibe.network'], cap: ['x.y.z'], subject_id: staff.subject, iat: now(), exp: now() + 300 }, s.network.privatePem);
+ const me = await s.get('/auth/me', { cookie: `inventory_at=${token}` });
+ assert.strictEqual(me.status, 401);
+ });
+
+ await check('/auth/me: a guest is signed out (200 { user: null }); a session cookie that fails is 401', async () => {
+ const guest = await s.get('/auth/me');
+ assert.strictEqual(guest.status, 200, 'no session cookie at all: not an error');
+ assert.deepStrictEqual(JSON.parse(guest.text), { user: null });
+ assert.strictEqual(guest.headers.get('cache-control'), 'private, no-store');
+ assert.strictEqual((await s.get('/auth/me', { cookie: 'inventory_at=garbage' })).status, 401, 'a present but invalid access cookie');
+ assert.strictEqual((await s.get('/auth/me', { cookie: 'inventory_rt=stale' })).status, 401, 'a refresh cookie alone is a session that did not resolve');
+ assert.strictEqual((await s.get('/auth/me', { as: staff })).status, 200);
+ });
+
+ await check('a real Network session token signs the person in', async () => {
+ const me = await s.get('/auth/me', { as: staff });
+ assert.strictEqual(me.status, 200, me.text.slice(0, 200));
+ assert.strictEqual(JSON.parse(me.text).user.username, staff.username);
+ });
+
+ await check('another site cannot sign a person out; this site can', async () => {
+ const cross = await s.get('/auth/logout', { as: staff, headers: { 'sec-fetch-site': 'cross-site' } });
+ assert.strictEqual(cross.status, 200, 'a confirm page, not a sign-out');
+ assert.ok(!/inventory_at=;|inventory_at=deleted/i.test(String(cross.headers.get('set-cookie') || '')), 'the session cookie is kept');
+ assert.match(cross.text, /method="post"/);
+ const crossPost = await s.get('/auth/logout', { as: staff, method: 'POST', headers: { 'sec-fetch-site': 'cross-site' } });
+ assert.strictEqual(crossPost.status, 403);
+ const same = await s.get('/auth/logout?next=/updates', { as: staff, headers: { 'sec-fetch-site': 'same-origin' } });
+ assert.strictEqual(same.status, 303);
+ assert.strictEqual(same.headers.get('location'), '/updates');
+ assert.match(String(same.headers.get('set-cookie') || ''), /inventory_at=;/);
+ });
+
+ await s.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+'use strict';
+/**
+ * openvibe-sdk/service (plan T16/J2): the entry point's graceful stop runs its close steps in order
+ * (the timers clear, the JWKS refresher stops, then the store closes: the order the manifest's
+ * shutdown.drains lists) and resolves exit 0; a second signal while stopping is a no-op.
+ */
+const assert = require('assert');
+const http = require('http');
+const { check, done } = require('./helpers/boot');
+const { createLifecycle } = require('../server/index');
+
+const listen = (server) => new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+
+(async () => {
+ await check('the entry point stop clears the timers, stops the JWKS refresher, then closes the store, then exits 0', async () => {
+ const steps = [];
+ const ctx = {
+ keys: { client: { stop: () => { steps.push('keys.stop'); } } },
+ s: { close: async () => { steps.push('store.close'); } },
+ };
+ const server = http.createServer((req, res) => res.end('ok'));
+ await listen(server);
+
+ const exits = [];
+ const timer = setInterval(() => steps.push('tick'), 1_000_000);
+ const lifecycle = createLifecycle({ server, ctx, exit: (code) => exits.push(code), signals: false, timers: [timer] });
+ const code = await lifecycle.stop('SIGTERM');
+
+ assert.strictEqual(code, 0);
+ assert.deepStrictEqual(exits, [0]);
+ assert.deepStrictEqual(steps, ['keys.stop', 'store.close']);
+ assert.strictEqual(server.listening, false);
+ });
+
+ await check('a second signal while stopping is a no-op', async () => {
+ let stops = 0; let closes = 0; let exits = 0;
+ const ctx = {
+ keys: { client: { stop: () => { stops += 1; } } },
+ s: { close: async () => { closes += 1; } },
+ };
+ const server = http.createServer((req, res) => res.end('ok'));
+ await listen(server);
+
+ const lifecycle = createLifecycle({ server, ctx, exit: () => { exits += 1; }, signals: false });
+ const first = lifecycle.stop('SIGTERM');
+ const second = lifecycle.stop('SIGINT');
+
+ assert.strictEqual(first, second);
+ assert.deepStrictEqual(await Promise.all([first, second]), [0, 0]);
+ assert.strictEqual(stops, 1);
+ assert.strictEqual(closes, 1);
+ assert.strictEqual(exits, 1);
+ });
+
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });