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

OP OpenVibers committed 9 Oct 2026 · dc84c80109 · 58 files, +8013 −0


Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..f24fa67
---- /dev/null
++++ b/.env.example
@@@ -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=
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..b4fba8f
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -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
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..c7d9202
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,11 @@
++node_modules/
++data/
++dist/
++.env
++.env.*
++!.env.example
++*.log
++*.pem
++*.key
++!server/data/
++node_modules
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
nnew file mode 100644
iindex 0000000..30c2f71
---- /dev/null
++++ b/CHANGELOG.md
@@@ -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.
ddiff --git a/LICENSE b/LICENSE
nnew file mode 100644
iindex 0000000..0ad25db
---- /dev/null
++++ b/LICENSE
@@@ -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/>.
ddiff --git a/README.md b/README.md
nnew file mode 100644
iindex 0000000..190f52f
---- /dev/null
++++ b/README.md
@@@ -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 -->
ddiff --git a/SECURITY.md b/SECURITY.md
nnew file mode 100644
iindex 0000000..49b94e7
---- /dev/null
++++ b/SECURITY.md
@@@ -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.
ddiff --git a/STATUS.json b/STATUS.json
nnew file mode 100644
iindex 0000000..bdeabcf
---- /dev/null
++++ b/STATUS.json
@@@ -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"
++}
ddiff --git a/deploy/nginx/inventory.openvibe.network.conf b/deploy/nginx/inventory.openvibe.network.conf
nnew file mode 100644
iindex 0000000..f80467c
---- /dev/null
++++ b/deploy/nginx/inventory.openvibe.network.conf
@@@ -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;
++ }
++}
ddiff --git a/deploy/systemd/openvibe-inventory.service b/deploy/systemd/openvibe-inventory.service
nnew file mode 100644
iindex 0000000..b32a5f6
---- /dev/null
++++ b/deploy/systemd/openvibe-inventory.service
@@@ -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
ddiff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql
nnew file mode 100644
iindex 0000000..a44b413
---- /dev/null
++++ b/migrations/0001_initial.sql
@@@ -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
++);
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..58a7a48
---- /dev/null
++++ b/package-lock.json
@@@ -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"
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..5994d50
---- /dev/null
++++ b/package.json
@@@ -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"
++ }
++}
ddiff --git a/public/css/app.css b/public/css/app.css
nnew file mode 100644
iindex 0000000..45a802b
---- /dev/null
++++ b/public/css/app.css
@@@ -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); }
ddiff --git a/scripts/import-live.js b/scripts/import-live.js
nnew file mode 100644
iindex 0000000..d64474e
---- /dev/null
++++ b/scripts/import-live.js
@@@ -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); });
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..44dd6dc
---- /dev/null
++++ b/server/app.js
@@@ -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 };
ddiff --git a/server/auth/keys.js b/server/auth/keys.js
nnew file mode 100644
iindex 0000000..665c4b5
---- /dev/null
++++ b/server/auth/keys.js
@@@ -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
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
nnew file mode 100644
iindex 0000000..5ca9e07
---- /dev/null
++++ b/server/auth/sso.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..6a7462f
---- /dev/null
++++ b/server/config.js
@@@ -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 };
ddiff --git a/server/data/kinds.json b/server/data/kinds.json
nnew file mode 100644
iindex 0000000..4578beb
---- /dev/null
++++ b/server/data/kinds.json
@@@ -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"
++ }
++ ]
++}
ddiff --git a/server/data/live-definitions.json b/server/data/live-definitions.json
nnew file mode 100644
iindex 0000000..2ec3aa7
---- /dev/null
++++ b/server/data/live-definitions.json
@@@ -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
++ }
++ }
++ ]
++}
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..296d11c
---- /dev/null
++++ b/server/db.js
@@@ -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 };
ddiff --git a/server/http/api.js b/server/http/api.js
nnew file mode 100644
iindex 0000000..fdeda62
---- /dev/null
++++ b/server/http/api.js
@@@ -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 };
ddiff --git a/server/http/caller-limits.js b/server/http/caller-limits.js
nnew file mode 100644
iindex 0000000..92c70e9
---- /dev/null
++++ b/server/http/caller-limits.js
@@@ -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 };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
nnew file mode 100644
iindex 0000000..551c91f
---- /dev/null
++++ b/server/http/discovery.js
@@@ -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 };
ddiff --git a/server/http/pages.js b/server/http/pages.js
nnew file mode 100644
iindex 0000000..a6c7092
---- /dev/null
++++ b/server/http/pages.js
@@@ -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 };
ddiff --git a/server/http/principal.js b/server/http/principal.js
nnew file mode 100644
iindex 0000000..4d3ba3e
---- /dev/null
++++ b/server/http/principal.js
@@@ -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 };
ddiff --git a/server/http/router.js b/server/http/router.js
nnew file mode 100644
iindex 0000000..088ba89
---- /dev/null
++++ b/server/http/router.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..5b72893
---- /dev/null
++++ b/server/index.js
@@@ -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 };
ddiff --git a/server/inventory/account-data.js b/server/inventory/account-data.js
nnew file mode 100644
iindex 0000000..f5c1ad7
---- /dev/null
++++ b/server/inventory/account-data.js
@@@ -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 };
ddiff --git a/server/inventory/import-live.js b/server/inventory/import-live.js
nnew file mode 100644
iindex 0000000..f7310bf
---- /dev/null
++++ b/server/inventory/import-live.js
@@@ -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 };
ddiff --git a/server/inventory/seed.js b/server/inventory/seed.js
nnew file mode 100644
iindex 0000000..68c6f0d
---- /dev/null
++++ b/server/inventory/seed.js
@@@ -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 };
ddiff --git a/server/inventory/store.js b/server/inventory/store.js
nnew file mode 100644
iindex 0000000..299c5d6
---- /dev/null
++++ b/server/inventory/store.js
@@@ -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 };
ddiff --git a/server/observability.js b/server/observability.js
nnew file mode 100644
iindex 0000000..d412f52
---- /dev/null
++++ b/server/observability.js
@@@ -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 };
ddiff --git a/server/render/html.js b/server/render/html.js
nnew file mode 100644
iindex 0000000..c0f69d9
---- /dev/null
++++ b/server/render/html.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 };
ddiff --git a/server/render/layout.js b/server/render/layout.js
nnew file mode 100644
iindex 0000000..cb5f5ed
---- /dev/null
++++ b/server/render/layout.js
@@@ -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 };
ddiff --git a/server/render/markdown.js b/server/render/markdown.js
nnew file mode 100644
iindex 0000000..f0ab0e7
---- /dev/null
++++ b/server/render/markdown.js
@@@ -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(/&amp;/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 };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..d0f63a1
---- /dev/null
++++ b/test/account-data.test.js
@@@ -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();
++})();
ddiff --git a/test/asset-cache.test.js b/test/asset-cache.test.js
nnew file mode 100644
iindex 0000000..f400022
---- /dev/null
++++ b/test/asset-cache.test.js
@@@ -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); });
ddiff --git a/test/auth-jwks.test.js b/test/auth-jwks.test.js
nnew file mode 100644
iindex 0000000..d4d2064
---- /dev/null
++++ b/test/auth-jwks.test.js
@@@ -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
ddiff --git a/test/auth-ops.test.js b/test/auth-ops.test.js
nnew file mode 100644
iindex 0000000..43f7218
---- /dev/null
++++ b/test/auth-ops.test.js
@@@ -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();
++})();
ddiff --git a/test/caller-limits.test.js b/test/caller-limits.test.js
nnew file mode 100644
iindex 0000000..19643a5
---- /dev/null
++++ b/test/caller-limits.test.js
@@@ -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();
++})();
ddiff --git a/test/discovery.test.js b/test/discovery.test.js
nnew file mode 100644
iindex 0000000..d1deb80
---- /dev/null
++++ b/test/discovery.test.js
@@@ -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();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
nnew file mode 100644
iindex 0000000..59055dd
---- /dev/null
++++ b/test/helpers/boot.js
@@@ -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 };
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
nnew file mode 100644
iindex 0000000..391aa5f
---- /dev/null
++++ b/test/helpers/db.js
@@@ -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 };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
nnew file mode 100644
iindex 0000000..8ec60c8
---- /dev/null
++++ b/test/helpers/mocks.js
@@@ -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 };
ddiff --git a/test/import-live.test.js b/test/import-live.test.js
nnew file mode 100644
iindex 0000000..3891d11
---- /dev/null
++++ b/test/import-live.test.js
@@@ -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();
++})();
ddiff --git a/test/inventory-api.test.js b/test/inventory-api.test.js
nnew file mode 100644
iindex 0000000..e12d37a
---- /dev/null
++++ b/test/inventory-api.test.js
@@@ -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();
++})();
ddiff --git a/test/layout.test.js b/test/layout.test.js
nnew file mode 100644
iindex 0000000..e0e11c5
---- /dev/null
++++ b/test/layout.test.js
@@@ -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); });
ddiff --git a/test/nginx-auth-limit.test.js b/test/nginx-auth-limit.test.js
nnew file mode 100644
iindex 0000000..6fd8d10
---- /dev/null
++++ b/test/nginx-auth-limit.test.js
@@@ -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');
ddiff --git a/test/no-internal-key.test.js b/test/no-internal-key.test.js
nnew file mode 100644
iindex 0000000..20761fe
---- /dev/null
++++ b/test/no-internal-key.test.js
@@@ -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();
++})();
ddiff --git a/test/open-redirect.test.js b/test/open-redirect.test.js
nnew file mode 100644
iindex 0000000..175c4fe
---- /dev/null
++++ b/test/open-redirect.test.js
@@@ -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');
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
nnew file mode 100644
iindex 0000000..f93ddb6
---- /dev/null
++++ b/test/perf-budget.test.js
@@@ -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; });
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..907b6cc
---- /dev/null
++++ b/test/run.js
@@@ -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); });
ddiff --git a/test/security-crawl.js b/test/security-crawl.js
nnew file mode 100644
iindex 0000000..cef6256
---- /dev/null
++++ b/test/security-crawl.js
@@@ -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 };
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
nnew file mode 100644
iindex 0000000..f2ac86e
---- /dev/null
++++ b/test/security-secrets.test.js
@@@ -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); });
ddiff --git a/test/security-session.test.js b/test/security-session.test.js
nnew file mode 100644
iindex 0000000..afebd5d
---- /dev/null
++++ b/test/security-session.test.js
@@@ -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();
++})();
ddiff --git a/test/service-kit.test.js b/test/service-kit.test.js
nnew file mode 100644
iindex 0000000..2a82b23
---- /dev/null
++++ b/test/service-kit.test.js
@@@ -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); });