openvibers/openvibe.play Commits

OpenVibe.Play 0.1.0: achievements and leaderboards for every OpenVibe game

OP OpenVibers committed 30 days ago · 8b228e4a61 · 52 files, +6134 −0

Titles registered by their own principal (play.title.manage), achievements and best/sum boards this week and all time,
idempotent score and unlock posts and guest adoption (play.result.write), a title's player reads (play.player.read),
public reads with CORS, server-rendered pages, and account export and deletion (ADR-033). Contracts 0.172.0.

Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..adc0c14
---- /dev/null
++++ b/.env.example
@@@ -0,0 +1,33 @@
++# OpenVibe.Play — environment (production: /etc/openvibe/play.env). Names only; never commit values.
++
++NODE_ENV=production
++PORT=5040
++HOST=127.0.0.1
++BASE_URL=https://play.openvibe.network
++TRUST_PROXY=2
++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh play: 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:play:
++# Per-caller limits (server/http/caller-limits.js): requests one caller may make to the API per minute and per hour.
++#PLAY_LIMITS_MINUTE=120
++#PLAY_LIMITS_HOUR=3000
++
++# OpenVibe.Network: SSO (OAuth client `play`, PKCE S256) and its JWKS; app tokens for audience openvibe.play (Network's rule: openvibe.<service>).
++OV_NETWORK_URL=https://openvibe.network
++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++OV_OAUTH_CLIENT_ID=play
++OV_OAUTH_CLIENT_SECRET=
++# OV_OAUTH_REDIRECT_URI=https://play.openvibe.network/auth/callback
++# PLAY_AUDIENCE=openvibe.play
++
++# optional: defaults to true in production
++# COOKIE_SECURE=true
++
++# OpenVibe.Events: PLAY_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
++#PLAY_EVENTS_SECRET=
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..08cc27f
---- /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: play
++ # 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..2b7fa6b
---- /dev/null
++++ b/CHANGELOG.md
@@@ -0,0 +1,9 @@
++# Changelog
++
++All notable changes to OpenVibe.Play.
++
++## 0.1.0 — 2026-10-14
++
++- First release (OpenVibe.Games ADR-0007 decision 10, contracts 0.172.0): titles registered by their own principal,
++ achievements (hidden ones too), best and sum leaderboards this week and all time, idempotent score and unlock posts,
++ guest adoption, public reads with CORS, server-rendered pages, and account export and deletion (ADR-033).
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..ab6f969
---- /dev/null
++++ b/README.md
@@@ -0,0 +1,65 @@
++# OpenVibe.Play
++
++**Achievements and leaderboards for every OpenVibe game.** https://play.openvibe.network
++
++OpenVibe.Play is the network's game services (OpenVibe.Games [ADR-0007](https://github.com/OpenVibers/OpenVibe.Games/blob/main/docs/adr/0007-rewrite-the-moddable-base.md)
++decision 10). A game registers its **title** with its own OpenVibe token, posts what its players did, and anyone reads
++its boards. The rules of each game stay in the game; Play keeps the record and answers it.
++
++- **Titles** belong to the principal that registered them (a game's service or app token). Only that principal writes to
++ a title; to anyone else it does not exist.
++- **Achievements**, including hidden ones whose name shows only once unlocked. A definition left out of an update is
++ retired, never deleted: what players earned stays.
++- **Leaderboards** of two kinds: `best` ranks each player's best score, `sum` the total of theirs. Each reads this week
++ (from Monday 00:00 UTC) or all time, 25 a page. A tie goes to whoever got there first.
++- **Players** are people (`usr_…`) or guests a game names (`guest:<opaque>`). When a guest signs in, the game adopts
++ them and their unlocks and scores become the person's.
++- **Idempotent writes.** A round's scores carry a `ref`; posting it again changes nothing. An unlock answers only the
++ achievements that were new.
++- **Personal data.** Only people's rows are: they are in the person's account export and erased with their account
++ (ADR-033). A board shows a name and a score, never a player id.
++
++Every page is server-rendered and readable without JavaScript: the home (every game and this week's leaders), a title,
++a board, your achievements and the API docs.
++
++## API (`/api/v1`, problem+json errors)
++
++| Route | Who | What |
++|---|---|---|
++| `GET /titles`, `GET /titles/:title` | anyone (CORS) | the games, their achievements (with how many players have each) and boards |
++| `GET /titles/:title/boards/:board?period=week\|all&page=` | anyone (CORS) | a board: `rank`, `name`, `score`, `signed_in` |
++| `GET /me/achievements` | a person | their unlocks in every game |
++| `PUT /titles/:title` | `play.title.manage` | register or update a title: name, url, summary, achievements, boards |
++| `POST /titles/:title/scores` | `play.result.write` | `{ board, ref, entries: [{ player, name, score }] }`, up to 100 |
++| `POST /titles/:title/unlocks` | `play.result.write` | `{ player, achievements: [id] }` → the new ones |
++| `POST /titles/:title/players/:guest/adopt` | `play.result.write` | `{ person: "usr_…" }`: a guest signed in |
++| `GET /titles/:title/players/:player/achievements` | `play.player.read` | a player's unlocks in your title |
++
++Tokens are Network service or app tokens for audience `openvibe.play`; the contracts are `play.*` in
++[openvibe-contracts](https://github.com/OpenVibers/OpenVibe.Contracts) (0.172.0).
++
++## Run
++
++```sh
++npm install
++npm run dev # http://localhost:5040, embedded PGlite in data/pglite
++npm test # every test on PGlite; npm run test:pg on PostgreSQL
++```
++
++Configuration is environment only (`.env.example`, names only). Production: unit `openvibe-play.service` on
++127.0.0.1:5040, database `ov_play`, nginx vhost `deploy/nginx/play.openvibe.network.conf` on the openvibe.network
++wildcard certificate, deployed with `sudo ovhost deploy play`.
++
++## Layout
++
++```
++server/play/store.js titles, achievements, boards, scores, unlocks, adoption
++server/play/account-data.js account export and deletion (ADR-033)
++server/http/api.js /api/v1
++server/http/pages.js the pages; discovery.js robots.txt, sitemap.xml, llms.txt
++migrations/0001_initial.sql the schema
++```
++
++## License
++
++AGPL-3.0. Part of the [OpenVibe network](https://openvibe.network).
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..16f9c00
---- /dev/null
++++ b/STATUS.json
@@@ -0,0 +1,47 @@
++{
++ "repository": "OpenVibers/OpenVibe.Play",
++ "stage": "alpha",
++ "code": true,
++ "wave": "T8",
++ "plan": "Plan track T8 (Games), OpenVibe.Games ADR-0007 decision 10 and M5: the platform's game services as their own service",
++ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
++ "port": "5040",
++ "domain": "play.openvibe.network",
++ "contracts": "openvibe-contracts v0.172.0",
++ "sdk": "openvibe-sdk v0.44.0",
++ "shared": "openvibe-shared v4.1.3",
++ "packages": [
++ "openvibe-contracts v0.172.0",
++ "openvibe-sdk v0.44.0",
++ "openvibe-shared v4.1.3"
++ ],
++ "summary": "OpenVibe.Play — achievements and leaderboards for every OpenVibe game (Games ADR-0007 decision 10). A title is registered by its own principal (a game's service or app token), which alone writes to it: achievements (with hidden ones), boards that rank each player's best score or the sum of theirs, this week and all time, idempotent score and unlock posts, and guests adopted by the person who signs in. Boards and titles are public reads (CORS) and pages; people's rows are in the account export and erased with the account.",
++ "works": [
++ "Titles registered and updated by their own principal (play.title.manage); a definition left out is retired, never deleted; a board never changes kind",
++ "Score posts to a board, idempotent by ref (up to 100 entries), and unlocks that answer the new ones (play.result.write)",
++ "Guest adoption: a guest's unlocks and scores become the person's when they sign in",
++ "Boards: each player's best or total, this week (from Monday UTC) or all time, 25 a page, names and scores only",
++ "Pages: home (every game, this week's leaders), a title, a board, your achievements, the API docs, /updates",
++ "Account export and deletion (ADR-033) at the loopback POST /internal/events, subscriptions created at boot"
++ ],
++ "notYet": [
++ "OpenVibe.Games posting to Play and reading its boards from it (then Games' local progress tables go)",
++ "Events (play.achievement.unlocked) for quests and inventory rewards",
++ "A title owned by a developer project through Services, and stats beyond scores",
++ "Presence and lobbies (decision 10's remaining services)"
++ ],
++ "capabilitiesRegistered": true,
++ "serviceManifestRegistered": true,
++ "deployed": false,
++ "placeholderInSites": false,
++ "features": [
++ "titles",
++ "achievements",
++ "leaderboards (best, sum; week, all time)",
++ "idempotent results",
++ "guest adoption",
++ "account export and deletion"
++ ],
++ "deployment": "openvibe-ovh: unit openvibe-play.service on 127.0.0.1:5040 (git checkout /opt/play.openvibe.network, env /etc/openvibe/play.env, database ov_play), deployed with `sudo ovhost deploy play`; nginx vhost from deploy/nginx (ov-vhost-install) on the openvibe.network wildcard certificate.",
++ "updated": "2026-10-14"
++}
ddiff --git a/deploy/nginx/play.openvibe.network.conf b/deploy/nginx/play.openvibe.network.conf
nnew file mode 100644
iindex 0000000..baf738d
---- /dev/null
++++ b/deploy/nginx/play.openvibe.network.conf
@@@ -0,0 +1,96 @@
++## ═══════════════════════════════════════════════════════════════
++## play.openvibe.network — Nginx configuration (REFERENCE)
++##
++## Deployed to /etc/nginx/sites-available/play.openvibe.network.conf (ov-vhost-install) — keep them in sync and reload
++## nginx after changing it. The Node app (port 5040, systemd unit openvibe-play) 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 and the API, 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=play_auth:10m rate=10r/m;
++limit_req_zone $binary_remote_addr zone=play_api:10m rate=5r/s;
++
++server {
++ listen 80;
++ listen [::]:80;
++ server_name play.openvibe.network;
++
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { return 301 https://play.openvibe.network$request_uri; }
++}
++
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name play.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/play.openvibe.network.access.log;
++ error_log /var/log/nginx/play.openvibe.network.error.log;
++
++ # The API's JSON body cap is 128 KB (a title's definitions, a round's scores); leave room for the headers.
++ client_max_body_size 160k;
++
++ 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=play_api burst=30 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5040;
++ }
++
++ location /auth/ {
++ limit_req zone=play_auth burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5040;
++ }
++
++ # ── API ─────────────────────────────────────────────────
++ location /api/ {
++ limit_req zone=play_api burst=20 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5040;
++ }
++
++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5040/metrics) ──
++ location ~* ^/metrics(/|$) { return 404; }
++
++ # ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──
++ # Events posts to http://127.0.0.1:5040/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:5040;
++ }
++
++ location ~ /\.(?!well-known) {
++ deny all;
++ }
++}
ddiff --git a/deploy/systemd/openvibe-play.service b/deploy/systemd/openvibe-play.service
nnew file mode 100644
iindex 0000000..49c642a
---- /dev/null
++++ b/deploy/systemd/openvibe-play.service
@@@ -0,0 +1,30 @@
++[Unit]
++Description=OpenVibe.Play — achievements and leaderboards for every OpenVibe game.
++# Network (sign-in, JWKS) should be up first, but OpenVibe.Play 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/play.openvibe.network
++EnvironmentFile=/etc/openvibe/play.env
++Environment=NODE_ENV=production
++Environment=PORT=5040
++Environment=PATH=/usr/local/bin:/usr/bin:/bin
++ExecStart=/usr/bin/env node /opt/play.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.Play'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..c1420b0
---- /dev/null
++++ b/migrations/0001_initial.sql
@@@ -0,0 +1,74 @@
++-- phase: expand
++-- OpenVibe.Play (OpenVibe.Games ADR-0007 decision 10): the platform's game services. A title is one game, managed by
++-- the principal that registered it. Play keeps what the title says players did — achievements unlocked and scores
++-- posted to its leaderboards — and answers the boards; the rules of each game stay in the game.
++--
++-- A player is a person's subject (usr_…) or a title-scoped guest the title names (guest:<opaque>); a guest's rows move
++-- to the person when the title adopts the guest. Only people's rows are personal data (account export and erasure).
++
++CREATE TABLE play_titles (
++ id text COLLATE "C" PRIMARY KEY CHECK (id ~ '^[a-z0-9][a-z0-9-]{1,39}$'),
++ owner text COLLATE "C" NOT NULL, -- the principal that manages it: service:games
++ name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 80),
++ url text CHECK (url IS NULL OR url ~ '^https://'), -- where it is played
++ summary text NOT NULL DEFAULT '' CHECK (char_length(summary) <= 300),
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL
++);
++
++CREATE TABLE play_achievements (
++ title_id text COLLATE "C" NOT NULL REFERENCES play_titles (id) ON DELETE CASCADE,
++ id text COLLATE "C" NOT NULL CHECK (id ~ '^[a-z0-9][a-z0-9_-]{0,63}$'),
++ name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 80),
++ description text NOT NULL DEFAULT '' CHECK (char_length(description) <= 300),
++ position integer NOT NULL DEFAULT 0,
++ hidden boolean NOT NULL DEFAULT false, -- a secret one: its name shows only once unlocked
++ retired boolean NOT NULL DEFAULT false, -- no longer in the title's definitions; unlocks stay
++ PRIMARY KEY (title_id, id)
++);
++
++CREATE TABLE play_boards (
++ title_id text COLLATE "C" NOT NULL REFERENCES play_titles (id) ON DELETE CASCADE,
++ id text COLLATE "C" NOT NULL CHECK (id ~ '^[a-z0-9][a-z0-9_-]{0,63}$'),
++ name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 80),
++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('best', 'sum')), -- a player's best score, or the sum of theirs
++ position integer NOT NULL DEFAULT 0,
++ retired boolean NOT NULL DEFAULT false,
++ PRIMARY KEY (title_id, id)
++);
++
++CREATE TABLE play_unlocks (
++ title_id text COLLATE "C" NOT NULL,
++ achievement_id text COLLATE "C" NOT NULL,
++ player text COLLATE "C" NOT NULL,
++ unlocked_at bigint NOT NULL,
++ PRIMARY KEY (title_id, achievement_id, player),
++ FOREIGN KEY (title_id, achievement_id) REFERENCES play_achievements (title_id, id) ON DELETE CASCADE
++);
++CREATE INDEX play_unlocks_player ON play_unlocks (player, unlocked_at DESC);
++
++-- One row per (round or run the title names, player): a repeated post changes nothing.
++CREATE TABLE play_scores (
++ title_id text COLLATE "C" NOT NULL,
++ board_id text COLLATE "C" NOT NULL,
++ ref text COLLATE "C" NOT NULL CHECK (char_length(ref) BETWEEN 1 AND 128),
++ player text COLLATE "C" NOT NULL,
++ player_name text NOT NULL CHECK (char_length(player_name) BETWEEN 1 AND 40),
++ score bigint NOT NULL CHECK (score >= 0),
++ at bigint NOT NULL,
++ seq bigint GENERATED ALWAYS AS IDENTITY, -- post order: the newest name of two posts at one time
++ PRIMARY KEY (title_id, board_id, ref, player),
++ FOREIGN KEY (title_id, board_id) REFERENCES play_boards (title_id, id) ON DELETE CASCADE
++);
++CREATE INDEX play_scores_board ON play_scores (title_id, board_id, at);
++CREATE INDEX play_scores_player ON play_scores (player);
++
++-- 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..2f07bda
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1401 @@
++{
++ "name": "openvibe-play",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-play",
++ "version": "0.1.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.172.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
++ "pg": "^8.23.0"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++ },
++ "node_modules/@electric-sql/pglite": {
++ "version": "0.5.10",
++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.10.tgz",
++ "integrity": "sha512-tXc/25U3ulOkiRQuTFdwIv+JhBqtMdAF7Gvo6CfZZWtSB4+hgC4Wr2vwK5ngvvN5PJfAAEGIsfyCVy1JhCOBYw==",
++ "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.172.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.172.0",
++ "integrity": "sha512-bPO0JSm3tpLHot/smAO559//quqERk9dz13P+cNLYRZooq3M2whnLz9BwDiDy3lO37cy5AJ/tW/EsgRmKpRLDQ==",
++ "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.44.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
++ "integrity": "sha512-8OjkIXVPUrGaOriBeHGK5GDoz4IRpjpKt7knZ5HSZnUpi0ND1Z3XD1YET4HiRydweT/rQy9/1h3oKod+tAP7Hg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "@electric-sql/pglite": "^0.5.8",
++ "iovalkey": "^0.4.0",
++ "pg": "^8.23.0"
++ },
++ "peerDependenciesMeta": {
++ "@electric-sql/pglite": {
++ "optional": true
++ },
++ "iovalkey": {
++ "optional": true
++ },
++ "pg": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/openvibe-shared": {
++ "version": "4.1.3",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
++ "integrity": "sha512-mXLBDTfoFWqnpHQdgpMvSx02v6m9vFy7k3RpKvltK7q4SSM4WQxa2jXzRGpHk0yVfIcoxDH6QqDWx0wNKobulA==",
++ "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..d8dd003
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,37 @@
++{
++ "name": "openvibe-play",
++ "version": "0.1.0",
++ "private": true,
++ "description": "OpenVibe.Play — achievements and leaderboards for every OpenVibe game.",
++ "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": "PLAY_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.172.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
++ "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..8d677a8
---- /dev/null
++++ b/public/css/app.css
@@@ -0,0 +1,88 @@
++/* OpenVibe.Play — 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; }
++
++/* ── OpenVibe.Play ─────────────────────────────────────────────── */
++.pl-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
++.pl-lede { color: var(--text-secondary, #96a7c2); max-width: 70ch; font-size: 1.05rem; }
++.pl-muted, .pl-meta { color: var(--text-secondary, #96a7c2); }
++.pl-meta { font-size: 0.875rem; }
++.pl-empty { color: var(--text-secondary, #96a7c2); padding: 14px 16px; border: 1px dashed var(--border, #1f2d47); border-radius: 10px; }
++.pl-crumbs { font-size: 0.875rem; color: var(--text-secondary, #96a7c2); margin: 0 0 4px; }
++.pl-crumbs a { color: inherit; }
++.pl-button { display: inline-block; padding: 9px 16px; border-radius: 10px; font-weight: 600; text-decoration: none;
++ background: var(--accent-strong, #1d4ed8); color: var(--on-accent-strong, #fff); }
++.pl-button:hover { filter: brightness(1.08); }
++
++.pl-titles { margin: 32px 0; }
++.pl-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr)); gap: 16px; }
++.pl-card { border: 1px solid var(--border, #1f2d47); border-radius: 14px; padding: 18px 18px 8px; background: var(--bg-card, #131c2e); }
++.pl-card h3 { margin: 0 0 4px; font-size: 1.25rem; }
++.pl-card h3 a { color: var(--text-primary, #e6edf7); text-decoration: none; }
++.pl-card h3 a:hover { text-decoration: underline; }
++.pl-card h4 { margin: 14px 0 6px; font-size: 0.95rem; color: var(--text-secondary, #96a7c2); font-weight: 600; }
++
++.pl-board { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
++.pl-board th { text-align: left; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-secondary, #96a7c2); font-weight: 600; }
++.pl-board th, .pl-board td { padding: 8px 10px; border-bottom: 1px solid var(--border, #1f2d47); }
++.pl-board tbody tr:last-child td { border-bottom: 0; }
++.pl-board .pl-n { text-align: right; }
++.pl-board .pl-rank { width: 3.5ch; color: var(--text-secondary, #96a7c2); }
++.pl-board tbody tr:nth-child(-n+3) .pl-rank { color: var(--accent, #3b82f6); font-weight: 700; }
++.pl-board-compact th, .pl-board-compact td { padding: 5px 6px; font-size: 0.925rem; }
++.pl-guest { font-size: 0.72rem; color: var(--text-secondary, #96a7c2); border: 1px solid var(--border, #1f2d47); border-radius: 999px; padding: 0 6px; margin-left: 4px; vertical-align: 1px; }
++
++.pl-ach { list-style: none; padding: 0; margin: 12px 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 10px; }
++.pl-ach li { border: 1px solid var(--border, #1f2d47); border-radius: 12px; padding: 12px 14px; background: var(--bg-card, #131c2e); display: flex; flex-direction: column; gap: 4px; }
++.pl-ach-name { font-weight: 700; color: var(--text-primary, #e6edf7); }
++.pl-ach-desc { color: var(--text-secondary, #96a7c2); font-size: 0.925rem; }
++.pl-ach-meta { font-size: 0.8rem; color: var(--text-secondary, #96a7c2); margin-top: auto; }
++.pl-ach-hidden { border-style: dashed; }
++.pl-ach-hidden .pl-ach-name { font-style: italic; font-weight: 600; color: var(--text-secondary, #96a7c2); }
++
++.pl-tabs { display: flex; gap: 6px; margin: 14px 0; }
++.pl-tabs a, .pl-tabs span { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); text-decoration: none; font-size: 0.9rem; }
++.pl-tabs span[aria-current] { background: var(--accent-strong, #1d4ed8); color: var(--on-accent-strong, #fff); border-color: transparent; font-weight: 600; }
++.pl-pages { display: flex; gap: 14px; align-items: center; justify-content: center; margin: 18px 0; color: var(--text-secondary, #96a7c2); }
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..1176a37
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,172 @@
++'use strict';
++
++/**
++ * OpenVibe.Play — Express app factory; server/index.js listens, tests build their own instance with a temp database
++ * and a mock Network.
++ *
++ * /, /t/:title, /me, /docs, /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 is play/store.js (titles, achievements, leaderboards) behind http/api.js and http/pages.js; the plumbing
++ * here (helmet, SSO, legal pages, static assets, the API mount, the 404 and the error handler) is the network's.
++ */
++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 { createNetworkSender } = require('openvibe-sdk/account-data');
++const { createPlay } = require('./play/store');
++const accountDataLib = require('./play/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, 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 });
++ const play = createPlay({ s });
++ // 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: Play cannot answer account events'); });
++ const ctx = { config, s, keys, sso, principal, log, play, accountData };
++
++ const app = express();
++ app.disable('x-powered-by');
++ app.set('trust proxy', config.trustProxy);
++ const release = require('openvibe-shared/release').createRelease({ service: 'play', root: path.join(__dirname, '..') });
++ require('./render/layout').setRelease(release.release);
++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'play', 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 → Play (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) => res.json({ status: 'ok', service: 'openvibe-play', version: VERSION }));
++ 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: 'play', service: 'play', host: 'play.openvibe.network', name: 'OpenVibe.Play', 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 ─────────────────────────────────────────────────
++ // Public reads from any site: the titles and their leaderboards answer every origin, without credentials (a game's
++ // own page draws its board with them). Everything else keeps the same-site resource policy and no CORS. A preflight
++ // is answered here.
++ const PUBLIC_READ = /^\/(?:ping|titles(?:\/[^/]+(?:\/boards\/[^/]+)?)?)$/;
++ app.use('/api/v1', (req, res, next) => {
++ if (!PUBLIC_READ.test(req.path) || !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
++ res.setHeader('Access-Control-Allow-Origin', '*');
++ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
++ if (req.method !== 'OPTIONS') return next();
++ res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
++ res.setHeader('Access-Control-Allow-Headers', 'Accept');
++ res.setHeader('Access-Control-Max-Age', '86400');
++ return res.status(204).end();
++ });
++ // Per address for the public; a game's server calls over loopback, unproxied, and is counted per caller instead
++ // (http/caller-limits.js play.write), so one busy game never shares one address's budget.
++ const internal = (req) => !req.headers['x-forwarded-for'] && !req.headers['x-real-ip'] && /^(::1|::ffff:127\.|127\.)/.test(String(req.socket.remoteAddress || ''));
++ app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.PLAY_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, skip: internal, 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.PLAY_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.Play]', 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..ad96f1f
---- /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.Play (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.Play] 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.Play] 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.Play] 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..13f2c28
---- /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 `play`.
++ *
++ * 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 (play_at) no script can read: with it, a script
++ * could act as them. The refresh token (play_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 = 'play_at';
++const REFRESH_COOKIE = 'play_rt';
++const HINT_COOKIE = 'ov_sso_hint';
++const FLOW_COOKIE = 'play_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.Play 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.Play] 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.Play?</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..823a93f
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,67 @@
++'use strict';
++
++/**
++ * OpenVibe.Play configuration. Every value comes from the environment (production: /etc/openvibe/play.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, 5040);
++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://play.openvibe.network' : `http://localhost:${port}`));
++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
++
++ return {
++ service: 'play',
++ 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.PLAY_LIMITS_MINUTE, 120)),
++ hour: Math.max(1, int(env.PLAY_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 (PLAY_PGLITE_DIR
++ // overrides the directory).
++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.PLAY_PGLITE_DIR || '' },
++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:play:' },
++
++ // 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.
++ // Network's rule for every grant: the audience is openvibe.<service> (not the site's host name).
++ audience: env.PLAY_AUDIENCE || 'openvibe.play',
++ oauth: {
++ clientId: env.OV_OAUTH_CLIENT_ID || 'play',
++ 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: PLAY_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. Play publishes no events of its own yet.
++ events: {
++ url: trim(env.EVENTS_URL || ''),
++ secrets: String(env.PLAY_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
++ },
++ };
++}
++
++module.exports = { load };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..126a345
---- /dev/null
++++ b/server/db.js
@@@ -0,0 +1,47 @@
++'use strict';
++
++/**
++ * OpenVibe.Play'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 play)');
++ const dir = config.db.pgliteDir || DEV_PGLITE;
++ log.warn(`[OpenVibe.Play] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
++ fs.mkdirSync(dir, { recursive: true });
++ const db = createDb({ pglite: dir, service: 'play', 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: 'play-migrate', max: 1, log });
++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
++ return createDb({ url: config.db.url, service: 'play', 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..e92a3a3
---- /dev/null
++++ b/server/http/api.js
@@@ -0,0 +1,88 @@
++'use strict';
++
++/**
++ * /api/v1 — OpenVibe.Play's API (capabilities: openvibe-contracts manifests/capabilities/play.*).
++ *
++ * GET /ping public liveness
++ * GET /titles public every title, with its achievements and boards
++ * GET /titles/:title public one title
++ * GET /titles/:title/boards/:board?period=&page= public a leaderboard: rank, name, score (never a player id)
++ * GET /me/achievements a person their own unlocks across titles
++ * PUT /titles/:title play.title.manage register or update a title the caller manages
++ * POST /titles/:title/scores play.result.write post a round's scores to a board (idempotent by ref)
++ * POST /titles/:title/unlocks play.result.write unlock achievements for a player (idempotent)
++ * POST /titles/:title/players/:guest/adopt play.result.write a guest signed in: their rows become the person's
++ * GET /titles/:title/players/:player/achievements play.player.read a player's unlocks in a title the caller manages
++ *
++ * Writes come from the principal that registered the title (a game's service token); another one is told the title
++ * does not exist. Public reads answer every origin without credentials. A refusal is RFC 9457 problem+json.
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { asyncRouter } = require('./router');
++const { PlayError } = require('../play/store');
++
++function createApi(ctx) {
++ const { config, principal, limits, play } = ctx;
++ const r = asyncRouter();
++
++ r.use(express.json({ limit: '128kb' }));
++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
++ r.use(principal.middleware);
++
++ const wrap = (fn) => async (req, res) => {
++ try { return await fn(req, res); } catch (err) {
++ if (err instanceof PlayError) return http.sendProblem(res, err.status, err.code, { detail: err.detail, ctx: req.ov });
++ throw err;
++ }
++ };
++ /** Writes and title-scoped reads are a principal's (a game's service or app token), never a person's. */
++ const titleOwner = (req, res, next) => {
++ if (req.principal.kind === 'app') return next();
++ return http.sendProblem(res, 403, 'play.title_principal_required', { detail: 'A title is managed with its game\'s own Network token (a service or app), not a person\'s.', ctx: req.ov });
++ };
++ const reads = limits.reads('play.read');
++
++ r.get('/ping', reads, (_req, res) => res.json({ ok: true, service: config.service }));
++
++ // ── Public reads ──────────────────────────────────────
++ r.get('/titles', reads, wrap(async (_req, res) => res.json({ titles: await play.titles() })));
++ r.get('/titles/:title', reads, wrap(async (req, res) => {
++ const t = await play.title(req.params.title);
++ return t ? res.json({ title: t }) : http.sendProblem(res, 404, 'play.unknown_title', { detail: 'no such title', ctx: req.ov });
++ }));
++ r.get('/titles/:title/boards/:board', reads, wrap(async (req, res) => {
++ const page = req.query.page === undefined ? 1 : Number(req.query.page);
++ return res.json(await play.board(req.params.title, req.params.board, { period: String(req.query.period || 'all'), page }));
++ }));
++
++ // ── A person's own ────────────────────────────────────
++ r.get('/me/achievements', reads, wrap(async (req, res) => {
++ if (req.principal.kind !== 'user') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at play.openvibe.network, or send your Network token.', ctx: req.ov });
++ return res.json({ titles: await play.playerAchievements(req.principal.requester.slice(5)) });
++ }));
++
++ // ── A title's own principal ───────────────────────────
++ r.put('/titles/:title', principal.requireCapability('play.title.manage'), titleOwner, limits.budget('play.define'), wrap(async (req, res) => {
++ const out = await play.define(req.principal.requester, req.params.title, req.body || {});
++ return res.status(out.created ? 201 : 200).json(out);
++ }));
++ r.post('/titles/:title/scores', principal.requireCapability('play.result.write'), titleOwner, limits.budget('play.write'), wrap(async (req, res) => {
++ res.json(await play.recordScores(req.principal.requester, req.params.title, req.body || {}));
++ }));
++ r.post('/titles/:title/unlocks', principal.requireCapability('play.result.write'), titleOwner, limits.budget('play.write'), wrap(async (req, res) => {
++ res.json(await play.unlock(req.principal.requester, req.params.title, req.body || {}));
++ }));
++ r.post('/titles/:title/players/:guest/adopt', principal.requireCapability('play.result.write'), titleOwner, limits.budget('play.write'), wrap(async (req, res) => {
++ res.json(await play.adopt(req.principal.requester, req.params.title, req.params.guest, String((req.body || {}).person || '')));
++ }));
++ r.get('/titles/:title/players/:player/achievements', principal.requireCapability('play.player.read'), titleOwner, reads, wrap(async (req, res) => {
++ await play.ownTitle(req.principal.requester, req.params.title);
++ const [t] = await play.playerAchievements(req.params.player, { titleId: req.params.title });
++ res.json({ title: req.params.title, player: req.params.player, achievements: t ? t.achievements : [] });
++ }));
++
++ 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..03b7ba4
---- /dev/null
++++ b/server/http/caller-limits.js
@@@ -0,0 +1,70 @@
++'use strict';
++
++/**
++ * Per-caller rate limits at OpenVibe.Play'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 play_rate_limited_total{limit,window}. Reads take PLAY_LIMITS_MINUTE /
++ * PLAY_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.
++ *
++ * 'play.thing.create': { minute: 6, hour: 60 },
++ */
++const BUDGETS = {
++ // Registering or updating a title's definitions: a deploy does it once.
++ 'play.define': { minute: 30, hour: 300 },
++ // Scores, unlocks and adoptions from a game's server: every finished round of every room, in bursts.
++ 'play.write': { minute: 1200, hour: 40000 },
++};
++
++function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
++ const refused = registry
++ ? registry.counter({ name: 'play_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..03e38db
---- /dev/null
++++ b/server/http/discovery.js
@@@ -0,0 +1,132 @@
++'use strict';
++
++/**
++ * Crawl artifacts for play.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 boards' public JSON is linked from llms.txt for agents that read it).
++ *
++ * The titles are read through ctx.play: the sitemap lists each game's page and each of its boards (this week and all
++ * time); llms.txt maps them with the read API; llms-full.txt spells out every achievement.
++ */
++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.Play';
++const DESCRIPTION = 'OpenVibe.Play: achievements and leaderboards for every OpenVibe game.';
++const DISALLOW = ['/auth/', '/api/', '/me'];
++
++const PAGE_TEXT = {
++ '/': ['OpenVibe.Play home', 'Every OpenVibe game with its achievements and this week\'s leaders.'],
++ '/docs': ['The Play API', 'How a game registers its achievements and leaderboards, posts scores and unlocks, and reads its boards.'],
++ '/updates': ['What shipped on OpenVibe.Play', '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.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
++ ];
++}
++
++const publicPages = () => [
++ { path: '/', changefreq: 'hourly', priority: 1.0 },
++ { path: '/docs', changefreq: 'monthly', priority: 0.6 },
++ { path: '/updates', changefreq: 'daily', priority: 0.4 },
++];
++const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
++
++function createDiscoveryRoutes(ctx) {
++ const { config, play } = ctx;
++ const r = asyncRouter();
++ const site = String(config.baseUrl).replace(/\/+$/, '');
++ const abs = (p) => `${site}${p}`;
++ const TEXT = cache.htmlHeaders({ maxAge: 3600 });
++ const boardPath = (t, b, period) => `/t/${t}/b/${b}?period=${period}`;
++
++ r.get('/robots.txt', (_req, res) => {
++ res.type('text/plain').set('Cache-Control', TEXT).send(
++ '# play.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', async (_req, res) => {
++ const titles = await play.titles();
++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
++ name: SITE_NAME,
++ summary: DESCRIPTION,
++ details: 'Each OpenVibe game registers its achievements and leaderboards here with its own token and posts what its '
++ + 'players did; the boards are public. A player is a person with an OpenVibe account or a guest of the game. '
++ + 'Every page is server-rendered and readable without JavaScript.',
++ sections: [
++ { title: 'Start here', links: [
++ { title: 'OpenVibe.Play', url: abs('/'), note: 'every game and this week\'s leaders' },
++ { title: 'The Play API', url: abs('/docs') },
++ { title: 'What shipped on OpenVibe.Play', url: abs('/updates') },
++ ] },
++ ...titles.map((t) => ({ title: t.name, links: [
++ { title: `${t.name}: achievements and leaderboards`, url: abs(`/t/${t.id}`), note: `${plural(t.achievements.length, 'achievement', 'achievements')}` },
++ ...t.boards.map((b) => ({ title: `${b.name}, this week`, url: abs(boardPath(t.id, b.id, 'week')) })),
++ ...t.boards.map((b) => ({ title: `${b.name} (JSON)`, url: abs(`/api/v1/titles/${t.id}/boards/${b.id}?period=all`) })),
++ ] })),
++ { title: 'API', links: [
++ { title: 'GET /api/v1/titles', url: abs('/api/v1/titles'), note: 'every game, its achievements and boards' },
++ ] },
++ { 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.Games', url: 'https://openvibe.games', note: 'play the games' },
++ { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts, apps and grants' },
++ ] },
++ ],
++ }));
++ });
++
++ r.get('/llms-full.txt', async (_req, res) => {
++ const titles = await play.titles();
++ const pages = publicPages().map((e) => ({ url: e.path, title: PAGE_TEXT[e.path][0], text: PAGE_TEXT[e.path][1] }));
++ const sections = titles.map((t) => ({
++ title: t.name,
++ pages: [
++ { title: `${t.name}: achievements and leaderboards`, url: `/t/${t.id}`, text: t.summary || `${plural(t.achievements.length, 'achievement', 'achievements')}` },
++ ...t.achievements.filter((a) => !a.hidden).map((a) => ({ title: a.name, url: `/t/${t.id}`, text: a.description || 'An achievement' })),
++ ...t.boards.map((b) => ({ title: b.name, url: boardPath(t.id, b.id, 'all'), text: b.kind === 'sum' ? 'Each player\'s total' : 'Each player\'s best score' })),
++ ],
++ }));
++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
++ site: SITE_NAME, summary: 'Every public page of OpenVibe.Play, one line each.', base: site, maxBytes: 64 * 1024,
++ sections: [{ title: 'Pages', pages }, ...sections],
++ }));
++ });
++
++ r.get('/sitemap.xml', async (_req, res) => {
++ const lastmod = siteUpdated();
++ const titles = await play.titles();
++ const entries = [
++ ...publicPages(),
++ ...titles.flatMap((t) => [
++ { path: `/t/${t.id}`, changefreq: 'daily', priority: 0.8 },
++ ...t.boards.flatMap((b) => [{ path: boardPath(t.id, b.id, 'week'), changefreq: 'hourly', priority: 0.6 }, { path: boardPath(t.id, b.id, 'all'), changefreq: 'daily', priority: 0.5 }]),
++ ]),
++ ];
++ res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(entries.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }))));
++ });
++
++ 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..cbf0c6a
---- /dev/null
++++ b/server/http/pages.js
@@@ -0,0 +1,185 @@
++'use strict';
++
++/**
++ * OpenVibe.Play's pages, server-rendered and usable without JavaScript:
++ *
++ * / the titles, each with its newest achievements and this week's leaders
++ * /t/:title one title: every achievement (how many players have it) and its boards
++ * /t/:title/b/:board one leaderboard, this week or all time, 25 a page
++ * /me a signed-in person's achievements across titles
++ * /docs the API for a game's developer
++ * /updates the update log
++ *
++ * A board shows a name and a score, never a player id.
++ */
++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 { asyncRouter } = require('./router');
++const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
++const { html, raw } = require('../render/html');
++const { send } = require('../render/layout');
++const { PlayError, PLAYER_RE } = require('../play/store');
++
++const SITE_NAME = 'OpenVibe.Play';
++const num = (n) => Number(n).toLocaleString('en-US');
++const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;
++const pct = (part, whole) => (whole > 0 ? `${Math.round((part / whole) * 100)}%` : '—');
++
++function boardTable(b, { compact = false } = {}) {
++ if (!b.entries.length) return html`<p class="pl-empty">${b.period === 'week' ? 'Nobody has scored this week yet.' : 'Nobody has scored yet.'}</p>`;
++ return html`<table class="pl-board${compact ? ' pl-board-compact' : ''}"><caption class="pl-sr">${b.name}, ${b.period === 'week' ? 'this week' : 'all time'}</caption>
++<thead><tr><th scope="col">#</th><th scope="col">Player</th><th scope="col" class="pl-n">${b.kind === 'sum' ? 'Total' : 'Best'}</th></tr></thead>
++<tbody>${b.entries.map((e) => html`<tr><td class="pl-rank">${String(e.rank)}</td><td>${e.name}${e.signed_in ? '' : raw(' <span class="pl-guest" title="Playing as a guest">guest</span>')}</td><td class="pl-n">${num(e.score)}</td></tr>`)}</tbody></table>`;
++}
++
++function achievementList(list, { players = 0 } = {}) {
++ if (!list.length) return html`<p class="pl-empty">No achievements yet.</p>`;
++ return html`<ul class="pl-ach">${list.map((a) => html`<li class="${a.hidden ? 'pl-ach-hidden' : ''}"><span class="pl-ach-name">${a.name}</span>${a.description ? html`<span class="pl-ach-desc">${a.description}</span>` : ''}${a.unlocked_by !== undefined ? html`<span class="pl-ach-meta">${plural(a.unlocked_by, 'player', 'players')}${players ? ` · ${pct(a.unlocked_by, players)}` : ''}</span>` : ''}${a.unlocked_at ? html`<span class="pl-ach-meta">Unlocked ${a.unlocked_at.slice(0, 10)}</span>` : ''}</li>`)}</ul>`;
++}
++
++function createPageRoutes(ctx) {
++ const { config, play } = ctx;
++ const r = asyncRouter();
++ const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 120 });
++ 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' && PLAYER_RE.test(String(req.viewer.subject || '')) && String(req.viewer.subject).startsWith('usr_') ? 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="/">See every game</a>.</p>` }, 404);
++
++ // ── Home ─────────────────────────────────────────────────
++ r.get('/', async (req, res) => {
++ const titles = await play.titles();
++ const me = signedIn(req);
++ const leaders = new Map();
++ for (const t of titles) {
++ if (t.boards.length) leaders.set(t.id, await play.board(t.id, t.boards[0].id, { period: 'week' }));
++ }
++ const hero = showcase.hero({
++ eyebrow: `${SITE_NAME} · part of OpenVibe`,
++ title: 'Achievements and leaderboards',
++ accent: 'for every OpenVibe game.',
++ lede: 'The scores you post and the achievements you unlock in OpenVibe games, in one place: this week\'s leaders, the all-time boards, and what you have earned with your OpenVibe account.',
++ actions: me
++ ? [{ label: 'Your achievements', href: '/me', primary: true }, { label: 'Play on OpenVibe.Games', href: 'https://openvibe.games/' }]
++ : [{ label: 'Play on OpenVibe.Games', href: 'https://openvibe.games/', primary: true }, { label: 'Sign in to see yours', href: '/auth/login?next=%2Fme' }],
++ note: 'Guests appear on the boards too; sign in and a game moves your guest progress to your account.',
++ });
++ page(req, res, {
++ index: true, cache: me ? null : PUBLIC_CACHE,
++ jsonLd: homeJsonLd(config),
++ styles: [showcase.STYLESHEET],
++ body: html`${raw(hero)}
++<section class="pl-titles" aria-labelledby="pl-titles-h"><h2 id="pl-titles-h">Games</h2>
++${titles.length ? html`<div class="pl-grid">${titles.map((t) => html`<article class="pl-card">
++<h3><a href="/t/${t.id}">${t.name}</a></h3>
++${t.summary ? html`<p class="pl-muted">${t.summary}</p>` : ''}
++<p class="pl-meta">${plural(t.achievements.length, 'achievement', 'achievements')} · ${plural(t.players, 'player', 'players')} with one${t.url ? html` · <a href="${t.url}">Play</a>` : ''}</p>
++${leaders.has(t.id) ? html`<h4>This week: ${leaders.get(t.id).name}</h4>${boardTable({ ...leaders.get(t.id), entries: leaders.get(t.id).entries.slice(0, 5) }, { compact: true })}<p><a href="/t/${t.id}/b/${leaders.get(t.id).board}?period=week">The whole board</a></p>` : ''}
++</article>`)}</div>` : html`<p class="pl-empty">No game has joined yet.</p>`}
++</section>
++${raw(showcase.steps({
++ title: 'How a game uses Play',
++ items: [
++ { title: 'Register the title', text: 'A game\'s server registers its title with its own OpenVibe token: its achievements and its boards.' },
++ { title: 'Post what happened', text: 'Scores for each round and the achievements a player unlocked, each idempotent, so a retry never counts twice.' },
++ { title: 'Read the boards anywhere', text: 'Boards are public JSON for any page: a best score or a total, this week or all time.' },
++ { title: 'Guests count', text: 'A guest plays at once; when they sign in, the game moves their progress to their account.', href: '/docs' },
++ ],
++ }))}`,
++ });
++ });
++
++ // ── A title ──────────────────────────────────────────────
++ r.get('/t/:title', async (req, res) => {
++ const t = await play.title(req.params.title);
++ if (!t) return notFound(req, res, 'No game here.');
++ const boards = [];
++ for (const b of t.boards) boards.push(await play.board(t.id, b.id, { period: 'week' }));
++ page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: `${t.name}: achievements and leaderboards`,
++ description: `${t.name} on OpenVibe.Play: ${plural(t.achievements.length, 'achievement', 'achievements')} and ${plural(t.boards.length, 'leaderboard', 'leaderboards')}, this week and all time.`,
++ body: html`<p class="pl-crumbs"><a href="/">Games</a> /</p>
++<h1>${t.name}</h1>
++${t.summary ? html`<p class="pl-lede">${t.summary}</p>` : ''}
++${t.url ? html`<p><a class="pl-button" href="${t.url}">Play ${t.name}</a></p>` : ''}
++${boards.length ? html`<section aria-labelledby="pl-boards-h"><h2 id="pl-boards-h">Leaderboards this week</h2>${boards.map((b) => html`<h3>${b.name}</h3>${boardTable({ ...b, entries: b.entries.slice(0, 10) })}<p><a href="/t/${t.id}/b/${b.board}?period=week">This week</a> · <a href="/t/${t.id}/b/${b.board}?period=all">All time</a></p>`)}</section>` : ''}
++<section aria-labelledby="pl-ach-h"><h2 id="pl-ach-h">Achievements</h2><p class="pl-muted">${plural(t.players, 'player has', 'players have')} unlocked at least one.</p>${achievementList(t.achievements, { players: t.players })}</section>`,
++ });
++ });
++
++ // ── A board ──────────────────────────────────────────────
++ r.get('/t/:title/b/:board', async (req, res) => {
++ const period = req.query.period === 'week' ? 'week' : 'all';
++ const pageNo = Math.max(1, Math.min(10_000, parseInt(req.query.page, 10) || 1));
++ let b;
++ try { b = await play.board(req.params.title, req.params.board, { period, page: pageNo }); } catch (err) {
++ if (err instanceof PlayError) return notFound(req, res, 'No board here.');
++ throw err;
++ }
++ const link = (o) => `/t/${b.title}/b/${b.board}?period=${o.period || period}${(o.page || 1) > 1 ? `&page=${o.page}` : ''}`;
++ page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: `${b.name}, ${period === 'week' ? 'this week' : 'all time'} · ${b.title_name}`,
++ description: `The ${b.name} leaderboard of ${b.title_name}, ${period === 'week' ? 'this week' : 'all time'}: ${b.kind === 'sum' ? 'each player\'s total' : 'each player\'s best score'}.`,
++ body: html`<p class="pl-crumbs"><a href="/">Games</a> / <a href="/t/${b.title}">${b.title_name}</a> /</p>
++<h1>${b.name}</h1>
++<p class="pl-muted">${b.kind === 'sum' ? 'Each player\'s total' : 'Each player\'s best score'}${period === 'week' ? `, since Monday ${String(b.since || '').slice(0, 10)} (UTC)` : ', all time'}. ${plural(b.total, 'player', 'players')}.</p>
++<nav class="pl-tabs" aria-label="Period">${period === 'week' ? html`<span aria-current="page">This week</span>` : html`<a href="${link({ period: 'week' })}">This week</a>`}${period === 'all' ? html`<span aria-current="page">All time</span>` : html`<a href="${link({ period: 'all' })}">All time</a>`}</nav>
++${boardTable(b)}
++${b.pages > 1 ? html`<nav class="pl-pages" aria-label="Pages">${pageNo > 1 ? html`<a href="${link({ page: pageNo - 1 })}" rel="prev">Previous</a>` : ''}<span>Page ${String(pageNo)} of ${String(b.pages)}</span>${pageNo < b.pages ? html`<a href="${link({ page: pageNo + 1 })}" rel="next">Next</a>` : ''}</nav>` : ''}`,
++ });
++ });
++
++ // ── Yours ────────────────────────────────────────────────
++ r.get('/me', async (req, res) => {
++ const me = signedIn(req);
++ if (!me) {
++ return page(req, res, { title: 'Your achievements', body: html`<h1>Your achievements</h1><p>Sign in with your OpenVibe account to see what you have unlocked in every game.</p><p><a class="pl-button" href="/auth/login?next=%2Fme">Sign in with OpenVibe</a></p>` });
++ }
++ const titles = await play.playerAchievements(me);
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ page(req, res, {
++ title: 'Your achievements',
++ body: html`<h1>Your achievements</h1>
++${titles.length ? titles.map((t) => html`<section><h2><a href="/t/${t.title}">${t.name}</a></h2><p class="pl-muted">${plural(t.achievements.length, 'achievement', 'achievements')}</p>${achievementList(t.achievements)}</section>`)
++ : html`<p class="pl-empty">Nothing yet. <a href="https://openvibe.games/">Play a game</a> signed in, and what you unlock shows here.</p>`}`,
++ });
++ });
++
++ // ── For developers ───────────────────────────────────────
++ r.get('/docs', (req, res) => page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: 'The Play API',
++ description: 'Register a game\'s achievements and leaderboards, post scores and unlocks, and read the boards: the OpenVibe.Play API.',
++ body: html`<h1>The Play API</h1>
++<p class="pl-lede">A game registers its title with its own OpenVibe token, posts what its players did, and reads its boards. Base address: <code>https://play.openvibe.network/api/v1</code>. Errors are RFC 9457 problem+json with a stable <code>code</code>.</p>
++<h2>Reading (anyone, any site)</h2>
++<ul>
++<li><code>GET /titles</code>: every game, with its achievements (how many players have each) and its boards.</li>
++<li><code>GET /titles/:title</code>: one game.</li>
++<li><code>GET /titles/:title/boards/:board?period=week|all&amp;page=1</code>: 25 entries a page: <code>rank</code>, <code>name</code>, <code>score</code>, and whether the player was signed in. Never a player id.</li>
++<li><code>GET /me/achievements</code>: with a person's Network token, their own unlocks in every game.</li>
++</ul>
++<h2>Writing (the game's own token)</h2>
++<p>A game's server uses a Network service or app token for audience <code>openvibe.play</code>. Only the principal that registered a title writes to it; to anyone else the title does not exist.</p>
++<ul>
++<li><code>PUT /titles/:title</code> (<code>play.title.manage</code>): <code>{ name, url, summary, achievements: [{ id, name, description, hidden }], boards: [{ id, name, kind: "best" | "sum" }] }</code>. A definition left out is retired, never deleted: what players earned stays. A board's kind never changes.</li>
++<li><code>POST /titles/:title/scores</code> (<code>play.result.write</code>): <code>{ board, ref, entries: [{ player, name, score }] }</code>, up to 100 entries. <code>ref</code> names the round or run; posting it again changes nothing.</li>
++<li><code>POST /titles/:title/unlocks</code> (<code>play.result.write</code>): <code>{ player, achievements: [id] }</code>; answers the ones that were new.</li>
++<li><code>POST /titles/:title/players/:guest/adopt</code> (<code>play.result.write</code>): <code>{ person: "usr_…" }</code> when a guest signs in: their unlocks and scores become the person's.</li>
++<li><code>GET /titles/:title/players/:player/achievements</code> (<code>play.player.read</code>): a player's unlocks in your game.</li>
++</ul>
++<p>A <code>player</code> is a person's OpenVibe subject (<code>usr_…</code>) or a guest your game names, <code>guest:</code> followed by 8 to 128 letters, digits, <code>_</code> or <code>-</code>. Only people's rows are personal data: they are in the person's account export and erased with their account.</p>`,
++ }));
++
++ r.get('/updates', (req, res) => page(req, res, {
++ index: true, cache: PUBLIC_CACHE,
++ title: `What shipped on ${SITE_NAME}`,
++ body: raw(frame.updatesBody({ service: 'play', 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..6bc8732
---- /dev/null
++++ b/server/http/principal.js
@@@ -0,0 +1,115 @@
++'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 openvibe.play; 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}$/;
++// One entry per capability the routes name (openvibe-contracts manifests/capabilities/play.*). 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 = ['play.title.manage', 'play.result.write', 'play.player.read'];
++
++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.Play'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 play.openvibe.network, or send a Network token (a person\'s, or an app\'s with this capability for play.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 play.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.Play'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..c2f52a9
---- /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', 'put']) {
++ 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..d2143dd
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,46 @@
++'use strict';
++
++/**
++ * OpenVibe.Play — process entry. `node server/index.js`
++ * Listens on PORT (5040) 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 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.Play', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
++ close: [() => { for (const t of timers) clearInterval(t); }, ...extra, () => 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.Play] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.s.db.store})`);
++ });
++ server.keepAliveTimeout = 65_000;
++ ctx.keys.client.start();
++ // The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
++ // PLAY_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,
++ });
++ createLifecycle({ server, ctx, extra: [() => { if (subscriptions) subscriptions.stop(); }] });
++ return { server, ctx };
++}
++
++if (require.main === module) {
++ start().catch((err) => { console.error('[OpenVibe.Play] failed to start:', err); process.exit(1); });
++}
++
++module.exports = { start, createLifecycle };
ddiff --git a/server/observability.js b/server/observability.js
nnew file mode 100644
iindex 0000000..8f39613
---- /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.Play'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: 'play',
++ 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/play/account-data.js b/server/play/account-data.js
nnew file mode 100644
iindex 0000000..739bf56
---- /dev/null
++++ b/server/play/account-data.js
@@@ -0,0 +1,23 @@
++'use strict';
++
++/**
++ * Account export and deletion → Play (ADR-033; openvibe-sdk/account-data). A person's rows are the ones whose player is
++ * their subject (usr_…); a guest's rows are nobody's account until a title adopts them.
++ *
++ * export the achievements they unlocked and the scores they posted, in every title
++ * deletion both are deleted: their unlocks stop counting and their scores leave every leaderboard
++ *
++ * Nothing exported is a secret.
++ */
++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
++
++const TABLES = [
++ { table: 'play_unlocks', subject: 'player', file: 'achievements.json', columns: ['title_id', 'achievement_id', 'unlocked_at'], order: 'unlocked_at' },
++ { table: 'play_scores', subject: 'player', file: 'scores.json', columns: ['title_id', 'board_id', 'ref', 'player_name', 'score', 'at'], order: 'at' },
++];
++
++function create({ db, log = console } = {}) {
++ return createAccountData({ db, service: 'play', tables: TABLES, log });
++}
++
++module.exports = { create, TABLES, TOPICS };
ddiff --git a/server/play/store.js b/server/play/store.js
nnew file mode 100644
iindex 0000000..a078ecf
---- /dev/null
++++ b/server/play/store.js
@@@ -0,0 +1,242 @@
++'use strict';
++
++/**
++ * OpenVibe.Play's store: titles, their achievements and leaderboards, and what players did (migrations/0001_initial.sql).
++ *
++ * define(owner, titleId, doc) register or update a title the caller manages; definitions missing from doc
++ * are retired, never deleted (the unlocks and scores stay)
++ * recordScores(owner, titleId, post) { board, ref, entries: [{ player, name, score }], at? } → { recorded }
++ * unlock(owner, titleId, post) { player, achievements: [id…], at? } → { unlocked: [newly unlocked ids] }
++ * adopt(owner, titleId, guest, person) move a guest's unlocks and scores to the person who signed in
++ * board(titleId, boardId, { period, page }) ranked entries: name and score, never a player id
++ * title(id) / titles() the public definitions, with how many players unlocked each achievement
++ * playerAchievements(player, { titleId }) a player's unlocks across titles (or in one)
++ *
++ * Every write names the title it is for, and only the principal that registered a title writes to it: another
++ * principal is told the title does not exist. A player is a person (usr_…) or a guest the title names (guest:<opaque>).
++ */
++const PLAYER_RE = /^(usr_[0-9A-HJKMNP-TV-Z]{26}|guest:[A-Za-z0-9_-]{8,128})$/;
++const TITLE_RE = /^[a-z0-9][a-z0-9-]{1,39}$/;
++const DEF_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
++const PER_PAGE = 25;
++const MAX_ENTRIES = 100;
++const MAX_DEFS = 200;
++const EARLIEST = Date.UTC(2020, 0, 1);
++
++class PlayError 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 PlayError(status, code, detail); };
++const str = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '');
++
++/** Monday 00:00 UTC of the week `now` is in. */
++function weekStart(now) {
++ const d = new Date(now);
++ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - ((d.getUTCDay() + 6) % 7));
++}
++
++function createPlay({ s }) {
++ const { db } = s;
++
++ async function ownTitle(owner, titleId) {
++ if (!TITLE_RE.test(String(titleId || ''))) fail(404, 'play.unknown_title', 'no such title');
++ const t = await db.maybe('SELECT * FROM play_titles WHERE id = $1', [titleId]);
++ if (!t || t.owner !== owner) fail(404, 'play.unknown_title', 'no such title of yours');
++ return t;
++ }
++ const when = (at) => {
++ if (at === undefined || at === null) return s.now();
++ if (!Number.isSafeInteger(at) || at < EARLIEST || at > s.now() + 60_000) fail(400, 'play.bad_time', 'at is epoch milliseconds, not in the future');
++ return at;
++ };
++ const player = (p) => {
++ if (!PLAYER_RE.test(String(p || ''))) fail(400, 'play.bad_player', 'player is a person (usr_…) or guest:<opaque> (8 to 128 of A-Z a-z 0-9 _ -)');
++ return p;
++ };
++
++ function defsOf(list, what, extra) {
++ if (list === undefined) return null;
++ if (!Array.isArray(list) || list.length > MAX_DEFS) fail(400, 'play.bad_definitions', `${what} is a list of at most ${MAX_DEFS}`);
++ const seen = new Set();
++ return list.map((d, i) => {
++ const id = String((d && d.id) || '');
++ if (!DEF_RE.test(id) || seen.has(id)) fail(400, 'play.bad_definitions', `${what}[${i}].id must be a unique lowercase id`);
++ seen.add(id);
++ const name = str(d.name, 80);
++ if (!name) fail(400, 'play.bad_definitions', `${what}[${i}] needs a name`);
++ return { id, name, position: i, ...extra(d, i) };
++ });
++ }
++
++ async function define(owner, titleId, doc = {}) {
++ if (!TITLE_RE.test(String(titleId || ''))) fail(400, 'play.bad_title', 'a title id is 2 to 40 of a-z, 0-9 and -');
++ const name = str(doc.name, 80);
++ if (!name) fail(400, 'play.bad_title', 'a title needs a name');
++ const url = doc.url == null || doc.url === '' ? null : String(doc.url);
++ if (url !== null && !/^https:\/\/[^\s]{3,300}$/.test(url)) fail(400, 'play.bad_title', 'url is an https address');
++ const achievements = defsOf(doc.achievements, 'achievements', (d) => ({ description: str(d.description, 300), hidden: d.hidden === true }));
++ const boards = defsOf(doc.boards, 'boards', (d, i) => {
++ if (!['best', 'sum'].includes(d.kind)) fail(400, 'play.bad_definitions', `boards[${i}].kind is best or sum`);
++ return { kind: d.kind };
++ });
++ const now = s.now();
++ return await s.tx(async () => {
++ const cur = await db.maybe('SELECT * FROM play_titles WHERE id = $1 FOR UPDATE', [titleId]);
++ if (cur && cur.owner !== owner) fail(409, 'play.title_taken', 'another principal manages this title');
++ if (cur) await db.exec('UPDATE play_titles SET name = $2, url = $3, summary = $4, updated_at = $5 WHERE id = $1', [titleId, name, url, str(doc.summary, 300), now]);
++ else await db.exec('INSERT INTO play_titles (id, owner, name, url, summary, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $6)', [titleId, owner, name, url, str(doc.summary, 300), now]);
++ if (achievements) {
++ for (const a of achievements) {
++ await db.exec(`INSERT INTO play_achievements (title_id, id, name, description, position, hidden, retired) VALUES ($1, $2, $3, $4, $5, $6, false)
++ ON CONFLICT (title_id, id) DO UPDATE SET name = excluded.name, description = excluded.description, position = excluded.position, hidden = excluded.hidden, retired = false`,
++ [titleId, a.id, a.name, a.description, a.position, a.hidden]);
++ }
++ await db.exec('UPDATE play_achievements SET retired = true WHERE title_id = $1 AND NOT (id = ANY($2::text[]))', [titleId, achievements.map((a) => a.id)]);
++ }
++ if (boards) {
++ for (const b of boards) {
++ await db.exec(`INSERT INTO play_boards (title_id, id, name, kind, position, retired) VALUES ($1, $2, $3, $4, $5, false)
++ ON CONFLICT (title_id, id) DO UPDATE SET name = excluded.name, position = excluded.position, retired = false`,
++ [titleId, b.id, b.name, b.kind, b.position]);
++ const kind = await db.value('SELECT kind FROM play_boards WHERE title_id = $1 AND id = $2', [titleId, b.id]);
++ if (kind !== b.kind) fail(409, 'play.board_kind_fixed', `board ${b.id} is a ${kind} board: its kind never changes (its scores would mean something else)`);
++ }
++ await db.exec('UPDATE play_boards SET retired = true WHERE title_id = $1 AND NOT (id = ANY($2::text[]))', [titleId, boards.map((b) => b.id)]);
++ }
++ return { title: await title(titleId), created: !cur };
++ });
++ }
++
++ async function recordScores(owner, titleId, post = {}) {
++ await ownTitle(owner, titleId);
++ const board = await db.maybe('SELECT * FROM play_boards WHERE title_id = $1 AND id = $2', [titleId, String(post.board || '')]);
++ if (!board || board.retired) fail(404, 'play.unknown_board', 'no such board in this title');
++ const ref = str(post.ref, 128);
++ if (!ref) fail(400, 'play.bad_scores', 'ref names the round or run the scores come from (it makes a repeat change nothing)');
++ const entries = post.entries;
++ if (!Array.isArray(entries) || !entries.length || entries.length > MAX_ENTRIES) fail(400, 'play.bad_scores', `entries is a list of 1 to ${MAX_ENTRIES}`);
++ const at = when(post.at);
++ const rows = entries.map((e, i) => {
++ const score = e && e.score;
++ if (!Number.isSafeInteger(score) || score < 0) fail(400, 'play.bad_scores', `entries[${i}].score is a whole number, 0 or more`);
++ const name = str(e.name, 40);
++ if (!name) fail(400, 'play.bad_scores', `entries[${i}].name is the name to show`);
++ return [player(e.player), name, score];
++ });
++ let recorded = 0;
++ await s.tx(async () => {
++ for (const [p, name, score] of rows) {
++ recorded += await db.exec(`INSERT INTO play_scores (title_id, board_id, ref, player, player_name, score, at) VALUES ($1, $2, $3, $4, $5, $6, $7)
++ ON CONFLICT (title_id, board_id, ref, player) DO NOTHING`, [titleId, board.id, ref, p, name, score, at]);
++ }
++ });
++ return { recorded, repeated: rows.length - recorded };
++ }
++
++ async function unlock(owner, titleId, post = {}) {
++ await ownTitle(owner, titleId);
++ const p = player(post.player);
++ const ids = post.achievements;
++ if (!Array.isArray(ids) || !ids.length || ids.length > MAX_DEFS || !ids.every((x) => DEF_RE.test(String(x)))) fail(400, 'play.bad_unlock', 'achievements is a list of achievement ids');
++ const known = new Set((await db.many('SELECT id FROM play_achievements WHERE title_id = $1 AND NOT retired', [titleId])).map((r) => r.id));
++ const unknown = ids.filter((x) => !known.has(x));
++ if (unknown.length) fail(404, 'play.unknown_achievement', `not achievements of this title: ${unknown.slice(0, 5).join(', ')}`);
++ const at = when(post.at);
++ const unlocked = [];
++ await s.tx(async () => {
++ for (const id of [...new Set(ids)]) {
++ const n = await db.exec(`INSERT INTO play_unlocks (title_id, achievement_id, player, unlocked_at) VALUES ($1, $2, $3, $4)
++ ON CONFLICT (title_id, achievement_id, player) DO NOTHING`, [titleId, id, p, at]);
++ if (n) unlocked.push(id);
++ }
++ });
++ return { unlocked };
++ }
++
++ /** A guest signed in: their unlocks and scores in this title become the person's (the earliest unlock wins). */
++ async function adopt(owner, titleId, guest, person) {
++ await ownTitle(owner, titleId);
++ if (!/^guest:/.test(player(guest))) fail(400, 'play.bad_player', 'only a guest is adopted');
++ if (!/^usr_/.test(player(person))) fail(400, 'play.bad_player', 'a guest is adopted by a person (usr_…)');
++ return await s.tx(async () => {
++ const unlocks = await db.exec(`INSERT INTO play_unlocks (title_id, achievement_id, player, unlocked_at)
++ SELECT title_id, achievement_id, $3, unlocked_at FROM play_unlocks WHERE title_id = $1 AND player = $2
++ ON CONFLICT (title_id, achievement_id, player) DO UPDATE SET unlocked_at = LEAST(play_unlocks.unlocked_at, excluded.unlocked_at)`, [titleId, guest, person]);
++ await db.exec('DELETE FROM play_unlocks WHERE title_id = $1 AND player = $2', [titleId, guest]);
++ const scores = await db.exec(`INSERT INTO play_scores (title_id, board_id, ref, player, player_name, score, at)
++ SELECT title_id, board_id, ref, $3, player_name, score, at FROM play_scores WHERE title_id = $1 AND player = $2
++ ON CONFLICT (title_id, board_id, ref, player) DO NOTHING`, [titleId, guest, person]);
++ await db.exec('DELETE FROM play_scores WHERE title_id = $1 AND player = $2', [titleId, guest]);
++ return { unlocks, scores };
++ });
++ }
++
++ async function board(titleId, boardId, { period = 'all', page = 1 } = {}) {
++ const b = await db.maybe(`SELECT b.*, t.name AS title_name FROM play_boards b JOIN play_titles t ON t.id = b.title_id
++ WHERE b.title_id = $1 AND b.id = $2`, [String(titleId || ''), String(boardId || '')]);
++ if (!b) fail(404, 'play.unknown_board', 'no such board');
++ if (!['all', 'week'].includes(period)) fail(400, 'play.bad_query', 'period is all or week');
++ if (!Number.isSafeInteger(page) || page < 1 || page > 10_000) fail(400, 'play.bad_query', 'page is 1 or more');
++ const since = period === 'week' ? weekStart(s.now()) : 0;
++ // A player's best score (best) or the sum of theirs (sum) in the period. A tie goes to whoever got there
++ // first: the earliest post of a best score, the earlier last post of a sum. The name shown is the newest one.
++ const ranked = b.kind === 'sum'
++ ? 'SELECT player, SUM(score)::bigint AS score, MAX(at) AS reached FROM s GROUP BY player'
++ : 'SELECT player, MAX(score)::bigint AS score, NULL::bigint AS reached FROM s GROUP BY player';
++ const rows = await db.many(`WITH s AS (SELECT player, player_name, score, at, seq FROM play_scores WHERE title_id = $1 AND board_id = $2 AND at >= $3),
++ r0 AS (${ranked}),
++ r AS (SELECT r0.player, r0.score,
++ COALESCE(r0.reached, (SELECT MIN(at) FROM s WHERE s.player = r0.player AND s.score = r0.score)) AS reached FROM r0)
++ SELECT r.player, r.score, r.reached, (SELECT player_name FROM s WHERE s.player = r.player ORDER BY at DESC, seq DESC LIMIT 1) AS name,
++ COUNT(*) OVER () AS total
++ FROM r ORDER BY r.score DESC, r.reached ASC, r.player ASC LIMIT $4 OFFSET $5`, [b.title_id, b.id, since, PER_PAGE, (page - 1) * PER_PAGE]);
++ const total = rows.length ? Number(rows[0].total) : 0;
++ return {
++ title: b.title_id, title_name: b.title_name, board: b.id, name: b.name, kind: b.kind, period, page,
++ pages: Math.max(1, Math.ceil(total / PER_PAGE)), total, ...(period === 'week' ? { since: new Date(since).toISOString() } : {}),
++ entries: rows.map((r, i) => ({ rank: (page - 1) * PER_PAGE + i + 1, name: r.name, score: Number(r.score), signed_in: r.player.startsWith('usr_') })),
++ };
++ }
++
++ async function title(id) {
++ const t = await db.maybe('SELECT id, name, url, summary, created_at, updated_at FROM play_titles WHERE id = $1', [String(id || '')]);
++ if (!t) return null;
++ const players = Number(await db.value('SELECT COUNT(DISTINCT player) FROM play_unlocks WHERE title_id = $1', [t.id]));
++ const achievements = await db.many(`SELECT a.id, a.name, a.description, a.hidden, COUNT(u.player)::int AS unlocked_by
++ FROM play_achievements a LEFT JOIN play_unlocks u ON u.title_id = a.title_id AND u.achievement_id = a.id
++ WHERE a.title_id = $1 AND NOT a.retired GROUP BY a.title_id, a.id ORDER BY a.position, a.id`, [t.id]);
++ const boards = await db.many('SELECT id, name, kind FROM play_boards WHERE title_id = $1 AND NOT retired ORDER BY position, id', [t.id]);
++ return {
++ id: t.id, name: t.name, url: t.url, summary: t.summary, players,
++ achievements: achievements.map((a) => (a.hidden
++ ? { id: a.id, hidden: true, unlocked_by: a.unlocked_by, name: 'A secret achievement', description: '' }
++ : { id: a.id, name: a.name, description: a.description, unlocked_by: a.unlocked_by })),
++ boards,
++ created_at: new Date(Number(t.created_at)).toISOString(), updated_at: new Date(Number(t.updated_at)).toISOString(),
++ };
++ }
++
++ async function titles() {
++ const ids = (await db.many('SELECT id FROM play_titles ORDER BY created_at, id')).map((r) => r.id);
++ const out = [];
++ for (const id of ids) out.push(await title(id));
++ return out;
++ }
++
++ async function playerAchievements(p, { titleId = null } = {}) {
++ const rows = await db.many(`SELECT u.title_id, t.name AS title_name, a.id, a.name, a.description, u.unlocked_at
++ FROM play_unlocks u JOIN play_achievements a ON a.title_id = u.title_id AND a.id = u.achievement_id
++ JOIN play_titles t ON t.id = u.title_id
++ WHERE u.player = $1 AND ($2::text IS NULL OR u.title_id = $2) ORDER BY u.unlocked_at DESC`, [player(p), titleId]);
++ const by = new Map();
++ for (const r of rows) {
++ if (!by.has(r.title_id)) by.set(r.title_id, { title: r.title_id, name: r.title_name, achievements: [] });
++ by.get(r.title_id).achievements.push({ id: r.id, name: r.name, description: r.description, unlocked_at: new Date(Number(r.unlocked_at)).toISOString() });
++ }
++ return [...by.values()];
++ }
++
++ return { define, recordScores, unlock, adopt, board, title, titles, playerAchievements, ownTitle };
++}
++
++module.exports = { createPlay, PlayError, PLAYER_RE, TITLE_RE, weekStart, PER_PAGE };
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..a225423
---- /dev/null
++++ b/server/render/layout.js
@@@ -0,0 +1,126 @@
++'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.Play';
++const TAGLINE = 'Game achievements and leaderboards';
++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
++// The product's own navigation: one entry per public page it serves.
++const NAV = [
++ { label: 'Games', href: '/' },
++ { label: 'Your achievements', href: '/me' },
++ { label: 'For developers', href: '/docs' },
++ { 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: 'play',
++ 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: 'play', 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: 'play' })}
++<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="play@${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: 'play', 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..ee78959
---- /dev/null
++++ b/test/account-data.test.js
@@@ -0,0 +1,91 @@
++'use strict';
++/**
++ * ADR-033: Play'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 unlocks and scores are exported; her deletion removes them (her
++ * scores leave every board), leaves Bob's and a guest's rows 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_play', 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: { PLAY_EVENTS_SECRET: SECRET }, accountSend: createNetworkSender({ networkInternalUrl: stub.url, clientId: 'play', clientSecret: 'play-secret' }) });
++ const alice = t.network.addUser('alice');
++ const bob = t.network.addUser('bob');
++ const GUEST = 'guest:zz9plural9alpha';
++ 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 play = t.ctx.play;
++
++ try {
++ await check('alice, bob and a guest play a title', async () => {
++ await play.define('service:games', 'salvage-rush', { name: 'Salvage Rush', achievements: [{ id: 'first-haul', name: 'First Haul' }], boards: [{ id: 'best', name: 'Best round', kind: 'best' }] });
++ await play.recordScores('service:games', 'salvage-rush', { board: 'best', ref: 'r1', entries: [
++ { player: alice.subject, name: 'Alice', score: 400 }, { player: bob.subject, name: 'Bob', score: 300 }, { player: GUEST, name: 'Guest', score: 200 },
++ ] });
++ for (const p of [alice.subject, bob.subject, GUEST]) await play.unlock('service:games', 'salvage-rush', { player: p, achievements: ['first-haul'] });
++ });
++
++ await check('the export carries her unlocks and scores, and nothing of anyone else\'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_play');
++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['achievements.json', 'scores.json']);
++ assert.strictEqual(Number(part.body.files.find((f) => f.name === 'scores.json').content[0].score), 400);
++ assert.ok(!JSON.stringify(part.body).includes(bob.subject) && !JSON.stringify(part.body).includes(GUEST));
++ });
++
++ await check('the deletion removes her rows (she leaves the board) 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 play_unlocks WHERE player = $1', [alice.subject]), 0);
++ assert.strictEqual(await count('SELECT count(*) FROM play_scores WHERE player = $1', [alice.subject]), 0);
++ assert.strictEqual(await count('SELECT count(*) FROM play_scores WHERE player = ANY($1)', [[bob.subject, GUEST]]), 2, 'bob and the guest keep theirs');
++ const board = await play.board('salvage-rush', 'best');
++ assert.deepStrictEqual(board.entries.map((e) => e.name), ['Bob', 'Guest']);
++ 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.play_unlocks, conf[0].body.erased.play_scores], [1, 1]);
++ assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');
++ });
++
++ 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();
++})().catch((e) => { console.error(e); process.exit(1); });
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..4b8204d
---- /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: ['play.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: `play_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: `play_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: `play_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..ef8d52e
---- /dev/null
++++ b/test/auth-ops.test.js
@@@ -0,0 +1,112 @@
++'use strict';
++/**
++ * Sign-in with PKCE S256 (client id play, cookies play_at/play_rt/play_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'), 'play');
++ 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'), /play_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, /play_at=[^;]+;.*HttpOnly/i);
++ assert.match(set, /play_rt=[^;]+;.*HttpOnly/i);
++ const at = set.match(/play_at=([^;]+)/)[1];
++ const me = await t.get('/auth/me', { cookie: `play_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(/play_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, 'play');
++ 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: 'play' });
++ 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..d2580cf
---- /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
++ * PLAY_LIMITS_MINUTE/PLAY_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
++ * play_rate_limited_total.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { caller, BUDGETS } = require('../server/http/caller-limits');
++
++const READ = 'play.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: { PLAY_LIMITS_MINUTE: '3', PLAY_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: defining a title, posting results', () => {
++ assert.deepStrictEqual(Object.keys(BUDGETS).sort(), ['play.define', 'play.write']);
++ 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('play.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 play_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('play_rate_limited_total')).join('\n');
++ assert.ok(/play_rate_limited_total\{limit="play\.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..a2d7c6d
---- /dev/null
++++ b/test/discovery.test.js
@@@ -0,0 +1,48 @@
++'use strict';
++// Crawl artifacts (server/http/discovery.js): robots.txt keeps crawlers off sign-in, the API and /me; the sitemap lists
++// the public pages, each game and both periods of each board; llms.txt maps them with the read API; llms-full.txt
++// spells out the visible achievements and never a secret one.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ const games = { bearer: t.network.serviceToken('games', ['play.title.manage']) };
++ const put = await t.get('/api/v1/titles/scraplandia', { ...games, method: 'PUT', json: {
++ name: 'Scraplandia', url: 'https://openvibe.games/play', summary: 'Scrappy multiplayer survival-building.',
++ achievements: [{ id: 'first-haul', name: 'First Haul', description: 'Extract a valuable.' }, { id: 'hidden-one', name: 'Ghost', description: 'Shh.', hidden: true }],
++ boards: [{ id: 'haul', name: 'Haul', kind: 'sum' }],
++ } });
++ assert.strictEqual(put.status, 201, put.text);
++
++ await check('robots.txt keeps crawlers off sign-in, the API and /me, and names the sitemap', async () => {
++ const r = await t.get('/robots.txt');
++ assert.strictEqual(r.status, 200);
++ for (const p of ['/auth/', '/api/', '/me']) assert.ok(r.text.includes(`Disallow: ${p}`), p);
++ assert.match(r.text, /Sitemap: https:\/\/play\.openvibe\.network\/sitemap\.xml/);
++ });
++
++ await check('the sitemap lists the pages, the game and both periods of its board', async () => {
++ const r = await t.get('/sitemap.xml');
++ assert.match(r.headers.get('content-type'), /xml/);
++ for (const loc of ['/', '/docs', '/updates', '/t/scraplandia', '/t/scraplandia/b/haul?period=week', '/t/scraplandia/b/haul?period=all']) {
++ assert.ok(r.text.includes(`<loc>https://play.openvibe.network${loc.replace('&', '&amp;')}</loc>`), loc);
++ }
++ });
++
++ await check('llms.txt maps the game, its board and the read API', async () => {
++ const r = await t.get('/llms.txt');
++ assert.match(r.text, /^# OpenVibe\.Play/m);
++ assert.match(r.text, /Scraplandia: achievements and leaderboards/);
++ assert.match(r.text, /\/api\/v1\/titles\/scraplandia\/boards\/haul\?period=all/);
++ });
++
++ await check('llms-full.txt spells out the visible achievements, never a secret one', async () => {
++ const r = await t.get('/llms-full.txt');
++ assert.match(r.text, /First Haul/);
++ assert.doesNotMatch(r.text, /Ghost|Shh/);
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
nnew file mode 100644
iindex 0000000..b8b2db0
---- /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.PLAY_RATE_LIMIT_PER_MIN = process.env.PLAY_RATE_LIMIT_PER_MIN || '100000';
++process.env.PLAY_API_RATE_LIMIT_PER_MIN = process.env.PLAY_API_RATE_LIMIT_PER_MIN || '100000';
++/**
++ * Boots OpenVibe.Play 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 (play_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://play.openvibe.network', TRUST_PROXY: '1',
++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
++ OV_OAUTH_CLIENT_ID: 'play', OV_OAUTH_CLIENT_SECRET: 'play-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) => `play_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..44f6ab5
---- /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
++ * PLAY_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.PLAY_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'play', max });
++
++module.exports = { testDb, pgAvailable };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
nnew file mode 100644
iindex 0000000..37727ba
---- /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 `play`, refresh_token,
++ * client_credentials for `play` 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 === 'play') {
++ if (body.client_secret !== 'play-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:play', 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. games with play.result.write). */
++ serviceToken(slug, cap, { audience = 'openvibe.play' } = {}) {
++ 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/layout.test.js b/test/layout.test.js
nnew file mode 100644
iindex 0000000..037504c
---- /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="(play@[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], `play@${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.Play: Game achievements and leaderboards</title>'), 'home title');
++ assert.ok(head.includes('<link rel="canonical" href="https://play.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', 'WebPage'], 'the home JSON-LD');
++ for (const tag of ['<meta property="og:title"', 'data-ov-icon="play"', '/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.Play'), 'the service name is on the page');
++ assert.ok(r.text.includes('Achievements and leaderboards'), 'what it 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..cd0029d
---- /dev/null
++++ b/test/nginx-auth-limit.test.js
@@@ -0,0 +1,20 @@
++'use strict';
++// deploy/nginx/play.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/play.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=play_api burst=30 nodelay;/);
++assert.match(auth, /limit_req zone=play_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..ac0359a
---- /dev/null
++++ b/test/perf-budget.test.js
@@@ -0,0 +1,64 @@
++'use strict';
++// Size budgets for play.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: 20.6, // 18.7 (app.css 7.1 with Play's cards, boards and achievement tiles, plus showcase.css; 2026-10-14)
++ 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: '', PLAY_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 (PLAY_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/play-api.test.js b/test/play-api.test.js
nnew file mode 100644
iindex 0000000..dbd4f1c
---- /dev/null
++++ b/test/play-api.test.js
@@@ -0,0 +1,151 @@
++'use strict';
++// OpenVibe.Play's API: a game registers its title with its own token, posts scores and unlocks (idempotent), adopts a
++// guest who signed in, and anyone reads the boards — names and scores, never a player id. Only the principal that
++// registered a title writes to it.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const P1 = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0';
++const P2 = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ1';
++const G1 = 'guest:a1b2c3d4e5f6';
++const DAY = 86_400_000;
++
++(async () => {
++ let clock = Date.UTC(2026, 9, 14, 12); // a Wednesday
++ const t = await boot({ now: () => clock });
++ try {
++ const games = { bearer: t.network.serviceToken('games', ['play.title.manage', 'play.result.write', 'play.player.read']) };
++ const other = { bearer: t.network.serviceToken('quest', ['play.title.manage', 'play.result.write', 'play.player.read']) };
++ const define = (body, auth = games) => t.get('/api/v1/titles/salvage-rush', { ...auth, method: 'PUT', json: body });
++ const DOC = {
++ name: 'Salvage Rush', url: 'https://openvibe.games/play?game=salvage-rush', summary: '90-second scrap rounds.',
++ achievements: [
++ { id: 'first-haul', name: 'First Haul', description: 'Finish a round with scrap.' },
++ { id: 'big-haul', name: 'Big Haul', description: 'Score 500 in one round.' },
++ { id: 'secret-one', name: 'Ghost', description: 'Nobody knows.', hidden: true },
++ ],
++ boards: [{ id: 'best', name: 'Best round', kind: 'best' }, { id: 'hauls', name: 'Total haul', kind: 'sum' }],
++ };
++
++ await check('a game registers its title with its own token; nobody else may write to it', async () => {
++ const anon = await t.get('/api/v1/titles/salvage-rush', { method: 'PUT', json: DOC });
++ assert.strictEqual(anon.status, 401);
++ const person = await t.get('/api/v1/titles/salvage-rush', { bearer: t.network.userToken(t.network.addUser('ada')), method: 'PUT', json: DOC });
++ assert.strictEqual(person.status, 403, 'a person never manages a title');
++ const noCap = await define(DOC, { bearer: t.network.serviceToken('games', ['play.player.read']) });
++ assert.strictEqual(noCap.status, 403);
++ const created = await define(DOC);
++ assert.strictEqual(created.status, 201, created.text);
++ assert.deepStrictEqual(created.json().title.boards.map((b) => [b.id, b.kind]), [['best', 'best'], ['hauls', 'sum']]);
++ assert.strictEqual((await define(DOC)).status, 200, 'the same document again changes nothing');
++ const taken = await define(DOC, other);
++ assert.deepStrictEqual([taken.status, taken.json().code], [409, 'play.title_taken']);
++ const scores = await t.get('/api/v1/titles/salvage-rush/scores', { ...other, json: { board: 'best', ref: 'r', entries: [{ player: P1, name: 'x', score: 1 }] } });
++ assert.deepStrictEqual([scores.status, scores.json().code], [404, 'play.unknown_title'], 'another principal is told the title does not exist');
++ });
++
++ await check('scores and unlocks are idempotent; bad players, boards and achievements are refused', async () => {
++ const post = (body) => t.get('/api/v1/titles/salvage-rush/scores', { ...games, json: body });
++ const r1 = await post({ board: 'best', ref: 'round-1', entries: [{ player: P1, name: 'Ada', score: 300 }, { player: G1, name: 'Guest 7', score: 450 }] });
++ assert.deepStrictEqual(r1.json(), { recorded: 2, repeated: 0 });
++ assert.deepStrictEqual((await post({ board: 'best', ref: 'round-1', entries: [{ player: P1, name: 'Ada', score: 9999 }] })).json(), { recorded: 0, repeated: 1 }, 'a repeated round changes nothing');
++ for (const [body, code] of [
++ [{ board: 'nope', ref: 'x', entries: [{ player: P1, name: 'a', score: 1 }] }, 'play.unknown_board'],
++ [{ board: 'best', ref: 'x', entries: [{ player: 'bob', name: 'a', score: 1 }] }, 'play.bad_player'],
++ [{ board: 'best', ref: 'x', entries: [{ player: P1, name: 'a', score: -1 }] }, 'play.bad_scores'],
++ [{ board: 'best', entries: [{ player: P1, name: 'a', score: 1 }] }, 'play.bad_scores'],
++ [{ board: 'best', ref: 'x', at: clock + DAY, entries: [{ player: P1, name: 'a', score: 1 }] }, 'play.bad_time'],
++ ]) assert.strictEqual((await post(body)).json().code, code, JSON.stringify(body));
++ const unlock = (body) => t.get('/api/v1/titles/salvage-rush/unlocks', { ...games, json: body });
++ assert.deepStrictEqual((await unlock({ player: P1, achievements: ['first-haul'] })).json(), { unlocked: ['first-haul'] });
++ assert.deepStrictEqual((await unlock({ player: P1, achievements: ['first-haul', 'big-haul'] })).json(), { unlocked: ['big-haul'] }, 'only the new ones');
++ assert.strictEqual((await unlock({ player: P1, achievements: ['made-up'] })).json().code, 'play.unknown_achievement');
++ assert.deepStrictEqual((await unlock({ player: G1, achievements: ['first-haul'] })).json(), { unlocked: ['first-haul'] });
++ });
++
++ await check('a best board ranks each player\'s best, a sum board their total; this week starts on Monday', async () => {
++ const post = (body) => t.get('/api/v1/titles/salvage-rush/scores', { ...games, json: body });
++ clock += 60_000; // round 2 comes a minute after round 1
++ await post({ board: 'best', ref: 'round-2', entries: [{ player: P1, name: 'Ada L.', score: 500 }, { player: P2, name: 'Bo', score: 450 }] });
++ await post({ board: 'best', ref: 'old-round', at: clock - 5 * DAY, entries: [{ player: P2, name: 'Bo', score: 900 }] });
++ await post({ board: 'hauls', ref: 'x-1', entries: [{ player: P1, name: 'Ada L.', score: 20 }, { player: P2, name: 'Bo', score: 25 }] });
++ await post({ board: 'hauls', ref: 'x-2', entries: [{ player: P1, name: 'Ada L.', score: 10 }] });
++ const all = (await t.get('/api/v1/titles/salvage-rush/boards/best?period=all')).json();
++ assert.deepStrictEqual(all.entries, [
++ { rank: 1, name: 'Bo', score: 900, signed_in: true },
++ { rank: 2, name: 'Ada L.', score: 500, signed_in: true },
++ { rank: 3, name: 'Guest 7', score: 450, signed_in: false },
++ ], 'best per player, the newest name, a guest shown as one, no player ids');
++ assert.ok(!JSON.stringify(all).includes('usr_') && !JSON.stringify(all).includes('guest:'), 'never a player id');
++ const week = (await t.get('/api/v1/titles/salvage-rush/boards/best?period=week')).json();
++ assert.deepStrictEqual(week.entries.map((e) => [e.name, e.score]), [['Ada L.', 500], ['Guest 7', 450], ['Bo', 450]], 'last Friday\'s 900 is not this week; the guest reached 450 a round before Bo');
++ assert.strictEqual(week.since, '2026-10-12T00:00:00.000Z');
++ const sums = (await t.get('/api/v1/titles/salvage-rush/boards/hauls')).json();
++ assert.deepStrictEqual(sums.entries.map((e) => [e.name, e.score]), [['Ada L.', 30], ['Bo', 25]]);
++ assert.strictEqual((await t.get('/api/v1/titles/salvage-rush/boards/best?period=year')).status, 400);
++ assert.strictEqual((await t.get('/api/v1/titles/salvage-rush/boards/nope')).status, 404);
++ });
++
++ await check('a guest who signs in brings their scores and unlocks; the earlier unlock wins', async () => {
++ const P3 = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ2';
++ await t.get('/api/v1/titles/salvage-rush/unlocks', { ...games, json: { player: P3, achievements: ['first-haul'] } });
++ const adopt = await t.get(`/api/v1/titles/salvage-rush/players/${encodeURIComponent(G1)}/adopt`, { ...games, json: { person: P3 } });
++ assert.deepStrictEqual(adopt.json(), { unlocks: 1, scores: 1 });
++ const board = (await t.get('/api/v1/titles/salvage-rush/boards/best')).json();
++ assert.deepStrictEqual(board.entries.find((e) => e.name === 'Guest 7'), { rank: 3, name: 'Guest 7', score: 450, signed_in: true }, 'the guest\'s score is the person\'s now');
++ const read = (await t.get(`/api/v1/titles/salvage-rush/players/${P3}/achievements`, games)).json();
++ assert.deepStrictEqual(read.achievements.map((a) => a.id), ['first-haul']);
++ assert.strictEqual((await t.get(`/api/v1/titles/salvage-rush/players/${P3}/achievements`, other)).status, 404, 'only the title\'s own principal reads its players');
++ const bad = await t.get(`/api/v1/titles/salvage-rush/players/${P3}/adopt`, { ...games, json: { person: P1 } });
++ assert.strictEqual(bad.json().code, 'play.bad_player', 'only a guest is adopted');
++ });
++
++ await check('the public title: achievements with how many players have each; a secret one stays secret', async () => {
++ const r = await t.get('/api/v1/titles/salvage-rush', { headers: { origin: 'https://example.org' } });
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', 'public reads answer any site');
++ const title = r.json().title;
++ assert.deepStrictEqual(title.achievements.map((a) => [a.id, a.unlocked_by]), [['first-haul', 2], ['big-haul', 1], ['secret-one', 0]]);
++ assert.deepStrictEqual(title.achievements[2], { id: 'secret-one', hidden: true, unlocked_by: 0, name: 'A secret achievement', description: '' });
++ assert.strictEqual(title.players, 2);
++ assert.deepStrictEqual((await t.get('/api/v1/titles')).json().titles.map((x) => x.id), ['salvage-rush']);
++ });
++
++ await check('a definition left out is retired, its unlocks kept; a board never changes kind', async () => {
++ const lessDoc = { ...DOC, achievements: DOC.achievements.filter((a) => a.id !== 'big-haul') };
++ assert.strictEqual((await define(lessDoc)).status, 200);
++ const ids = (await t.get('/api/v1/titles/salvage-rush')).json().title.achievements.map((a) => a.id);
++ assert.deepStrictEqual(ids, ['first-haul', 'secret-one']);
++ const flip = await define({ ...DOC, boards: [{ id: 'best', name: 'Best round', kind: 'sum' }] });
++ assert.deepStrictEqual([flip.status, flip.json().code], [409, 'play.board_kind_fixed']);
++ assert.strictEqual((await define(DOC)).status, 200, 'and back');
++ assert.deepStrictEqual((await t.get(`/api/v1/titles/salvage-rush/players/${P1}/achievements`, games)).json().achievements.map((a) => a.id).sort(), ['big-haul', 'first-haul'], 'unlocks survived the retirement');
++ });
++
++ await check('a person reads their own achievements with their token', async () => {
++ const ada = t.network.addUser('ada-me');
++ const me = await t.get('/api/v1/me/achievements', { bearer: t.network.userToken(ada) });
++ assert.strictEqual(me.status, 200);
++ assert.ok(Array.isArray(me.json().titles));
++ assert.strictEqual((await t.get('/api/v1/me/achievements')).status, 401);
++ });
++
++ await check('the pages: home, a title, a board with its tabs, the docs, and llms.txt name the game', async () => {
++ const home = await t.get('/');
++ assert.strictEqual(home.status, 200);
++ assert.match(home.text, /Salvage Rush/);
++ const title = await t.get('/t/salvage-rush');
++ assert.match(title.text, /<h1>Salvage Rush<\/h1>/);
++ assert.match(title.text, /A secret achievement/);
++ assert.doesNotMatch(title.text, /Nobody knows/, 'a secret achievement\'s text never shows');
++ const board = await t.get('/t/salvage-rush/b/best?period=week');
++ assert.match(board.text, /aria-current="page">This week</);
++ assert.doesNotMatch(board.text, /usr_|guest:/);
++ assert.strictEqual((await t.get('/t/nope')).status, 404);
++ assert.strictEqual((await t.get('/t/salvage-rush/b/nope')).status, 404);
++ assert.match((await t.get('/docs')).text, /POST \/titles\/:title\/scores/);
++ assert.match((await t.get('/llms.txt')).text, /Salvage Rush: achievements and leaderboards/);
++ assert.match((await t.get('/sitemap.xml')).text, /\/t\/salvage-rush\/b\/best\?period=week/);
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(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..22512c7
---- /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 = () => ['play-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..5ab0939
---- /dev/null
++++ b/test/security-session.test.js
@@@ -0,0 +1,69 @@
++'use strict';
++/**
++ * The play_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 = `play_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: `play_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: 'play_at=garbage' })).status, 401, 'a present but invalid access cookie');
++ assert.strictEqual((await s.get('/auth/me', { cookie: 'play_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(!/play_at=;|play_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') || ''), /play_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); });